diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 8c280a8c6cfc..cf7c3170ea6d 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -3,10 +3,11 @@ import React, {useImperativeHandle, useRef} from 'react'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; 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 +33,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,7 +131,7 @@ import type {TableHandle, TableMethods, TableProps} from './types'; * * ``` */ -function Table({ +function Table({ ref, title, data = [], @@ -140,28 +142,36 @@ function Table) { +}: TableProps) { 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 {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching({isItemInSearch}); + const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching({isItemInSearch}); - const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); + const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); - const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), data); + const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data}); - const listRef = useRef>(null); + // Apply the middleware + const filteredData = filterMiddleware(data); + const searchedData = searchMiddleware(filteredData); + const sortedData = sortMiddleware(searchedData); + const processedData = selectionMiddleware(sortedData); + + const listRef = useRef>(null); const tableMethods: TableMethods = { ...filterMethods, ...sortMethods, ...searchMethods, + ...selectionMethods, }; /** @@ -175,9 +185,9 @@ function Table]; + return listRef.current?.[property as keyof FlashListRef]; }, - }) as TableHandle; + }) as TableHandle; }); const originalDataLength = data?.length ?? 0; @@ -196,10 +206,11 @@ function Table 0 && (hasSearchString || hasActiveFilters); // eslint-disable-next-line react/jsx-no-constructed-context-values - const contextValue: TableContextValue = { + const contextValue: TableContextValue = { title, listRef, listProps, + selectionEnabled, processedData, originalDataLength, columns, @@ -214,7 +225,7 @@ function Table}>{children}; + return }>{children}; } export default Table; diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index e2a5eb0a3dc8..5d6d3574ac52 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -83,6 +83,7 @@ function TableBody({contentContainerStyle, ...props}: TableBodyProps) { > data={filteredAndSortedData} + showsVerticalScrollIndicator={false} ListEmptyComponent={isEmptyResult ? EmptyResultComponent : ListEmptyComponent} contentContainerStyle={[filteredAndSortedData.length === 0 && styles.flex1, listContentContainerStyle, contentContainerStyle]} keyboardShouldPersistTaps="handled" diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 6d83062e2716..505d6a991e0c 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, TableRowData} 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: TableRowData[]; /** The original length of the data array before any processing. */ originalDataLength: number; @@ -58,7 +61,7 @@ type TableContextValue = { +const defaultTableContextValue: TableContextValue = { listRef: React.createRef(), processedData: [], originalDataLength: 0, @@ -71,11 +74,13 @@ const defaultTableContextValue: TableContextValue = { activeSearchString: '', tableMethods: {} as TableMethods, filterConfig: undefined, - listProps: {} as SharedListProps, + listProps: {} as SharedListProps, hasActiveFilters: false, hasSearchString: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, + handleShiftRowSelection: () => {}, + handleRowSelection: () => {}, }; const TableContext = createContext(defaultTableContextValue); @@ -97,14 +102,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 bfa96b68d2d4..8161aa31c4a4 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -1,6 +1,7 @@ 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'; @@ -52,7 +53,7 @@ type TableHeaderProps = ViewProps & { function TableHeader({style, shouldHideHeaderWhenEmptySearch = true, ...props}: TableHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {columns, isEmptyResult, title, shouldUseNarrowTableLayout} = useTableContext(); + const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, selectionEnabled} = useTableContext(); if (shouldUseNarrowTableLayout && !title) { return null; @@ -62,6 +63,12 @@ function TableHeader({style, shouldHideHea return null; } + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + + if (selectionEnabled) { + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); + } + return ( ({style, shouldHideHea styles.gap3, // Use Grid on web when available (will override flex if supported) styles.dGrid, - !shouldUseNarrowTableLayout && {gridTemplateColumns: `repeat(${columns.length}, 1fr)`}, + !shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')}, style, ]} {...props} @@ -93,15 +100,28 @@ function TableHeader({style, shouldHideHea )} - {!shouldUseNarrowTableLayout && - columns.map((column) => { - return ( - - ); - })} + {!shouldUseNarrowTableLayout && ( + <> + {selectionEnabled && ( + + {}} + /> + + )} + + {columns.map((column) => { + return ( + + ); + })} + + )} ); } @@ -147,6 +167,7 @@ function TableHeaderColumn({column}: {colu styles.tableHeaderContentHeight, column.styling?.flex ? {flex: column.styling.flex} : styles.flex1, column.styling?.containerStyles, + !column.sortable && styles.cursorDefault, ]; return ( @@ -154,6 +175,7 @@ function TableHeaderColumn({column}: {colu accessible accessibilityLabel={column.label} accessibilityRole="button" + disabled={!column.sortable} sentryLabel={CONST.SENTRY_LABEL.TABLE_HEADER.SORTABLE_COLUMN} style={tableHeaderStyles} onPress={() => toggleSorting(column.key)} diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index ac648b24843e..6ccb623e9569 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import type {PressableStateCallbackType} from 'react-native'; +import type {GestureResponderEvent, KeyboardEvent, PressableStateCallbackType} from 'react-native'; import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback'; @@ -54,12 +55,17 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled} = useTableContext(); - const columnCount = columns.length; + const item = processedData[rowIndex]; const rowCount = processedData.length; const isLastRow = rowIndex === rowCount - 1; const isInteractive = interactive && !isLoading; + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + + if (selectionEnabled) { + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); + } const tableRowPressableStyles = [ styles.mh5, @@ -71,6 +77,7 @@ export default function TableRow({ shouldUseNarrowTableLayout && !isLoading && styles.pv4, !shouldUseNarrowTableLayout && !isLoading && styles.pv2, isLastRow ? styles.tableBottomRadius : styles.borderBottom, + item.selected && [styles.activeComponentBG, {borderColor: theme.buttonHoveredBG}], shouldUseNarrowTableLayout ? styles.tableRowHeightCompact : styles.tableRowHeight, ]; @@ -81,9 +88,19 @@ export default function TableRow({ styles.gap3, styles.dFlex, // Use Grid on web when available (will override flex if supported) - !shouldUseNarrowTableLayout && [styles.dGrid, {gridTemplateColumns: `repeat(${columnCount}, 1fr)`}], + !shouldUseNarrowTableLayout && [styles.dGrid, {gridTemplateColumns: gridTemplateColumns.join(' ')}], ]; + const tableRowPressableHoverStyle = (() => { + if (!isInteractive) { + return undefined; + } else if (item.selected) { + return styles.activeComponentBG; + } else { + return styles.hoveredComponentBG; + } + })(); + const renderChildren = (state: PressableStateCallbackType) => { if (typeof children === 'function') { return children(state); @@ -92,20 +109,25 @@ export default function TableRow({ return children; }; - return ( - { + if (event && event.shiftKey) { + tableMethods.handleMultipleRowSelection(item.keyForList); + return; + } - {...offlineWithFeedback} - > + tableMethods.handleSingleRowSelection(item.keyForList); + }; + + return ( + ) : ( - {renderChildren(state)} + + {selectionEnabled && ( + + handleCheckboxPress(event as unknown as MouseEvent)} + /> + + )} + {renderChildren(state)} + ) } diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 84a01bbd77e4..9f3f7bd7fb43 100644 --- a/src/components/Table/middlewares/filtering.ts +++ b/src/components/Table/middlewares/filtering.ts @@ -1,4 +1,5 @@ import {useState} from 'react'; +import {TableData} from '../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..959e478573e9 --- /dev/null +++ b/src/components/Table/middlewares/selection.ts @@ -0,0 +1,105 @@ +import {useRef, useState} from 'react'; +import {TableData, TableRow} from '../types'; +import {MiddlewareHookResult} from './types'; + +export type UseSelectionProps = { + data: DataType[]; +}; + +export type SelectionMethods = { + handleSelectAll: () => void; + + handleMultipleRowSelection: (keyForList: string) => void; + + handleSingleRowSelection: (keyForList: string) => void; +}; + +export type UseSelectionResult = MiddlewareHookResult>; + +export default function useSelection({data}: UseSelectionProps): UseSelectionResult { + const lastSelectedRowKeyRef = useRef(null); + const lastSelectedRowIsSelectedRef = useRef(false); + const [selectedKeys, setSelectedKeys] = useState([]); + + /** + * + */ + const handleSelectAll = () => {}; + + /** + * + */ + const handleMultipleRowSelection = (keyForList: string) => { + const keyForLists = data.map((item) => item.keyForList); + const keyForListExists = keyForLists.includes(keyForList); + + if (!keyForListExists) { + return; + } + + const lastSelectedRowKey = lastSelectedRowKeyRef.current; + const lastSelectedRowIsSelected = lastSelectedRowIsSelectedRef.current; + + if (!lastSelectedRowKey) { + handleSingleRowSelection(keyForList); + return; + } + + const currentSelectedRowIndex = keyForLists.indexOf(keyForList); + const lastSelectedRowIndex = keyForLists.indexOf(lastSelectedRowKey); + + if (currentSelectedRowIndex === -1 || lastSelectedRowIndex === -1) { + handleSingleRowSelection(keyForList); + return; + } + + const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); + const endIndex = Math.max(currentSelectedRowIndex, lastSelectedRowIndex); + + setSelectedKeys((prevSelectedKeys) => { + const newSelectedKeys = [...prevSelectedKeys]; + + for (let i = startIndex; i <= endIndex; i++) { + const key = keyForLists[i]; + if (lastSelectedRowIsSelected) { + if (!newSelectedKeys.includes(key)) { + newSelectedKeys.push(key); + } + } else { + const index = newSelectedKeys.indexOf(key); + if (index !== -1) { + newSelectedKeys.splice(index, 1); + } + } + } + + return newSelectedKeys; + }); + }; + + /** + * + */ + const handleSingleRowSelection = (keyForList: string) => { + setSelectedKeys((prevSelectedKeys) => { + if (prevSelectedKeys.includes(keyForList)) { + return prevSelectedKeys.filter((key) => key !== keyForList); + } else { + return [...prevSelectedKeys, keyForList]; + } + }); + }; + + const middleware = (data: DataType[]) => { + return data.map((item) => ({...item, selected: selectedKeys.includes(item.keyForList)})); + }; + + return { + middleware, + methods: { + handleSelectAll, + handleMultipleRowSelection, + handleSingleRowSelection, + }, + }; +} 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 6b6344f0536d..636cd59139f5 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -3,8 +3,13 @@ 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 {SelectionMethods} from './middlewares/selection'; import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middlewares/sorting'; +type TableData = { + keyForList: string; +}; + /** * Styling options for a table column. */ @@ -31,36 +36,46 @@ type TableColumn = { /** Display label shown in the table header. */ label: string; + /** 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; }; +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 & TableMethods; +type TableHandle = FlashListRef & TableMethods; /** * 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. @@ -69,7 +84,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. * @@ -89,13 +104,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>; @@ -117,22 +135,36 @@ 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>; }>; -export type {TableColumn, TableMethods, TableHandle, TableProps, SharedListProps, CompareItemsCallback, IsItemInFilterCallback, IsItemInSearchCallback, FilterConfig, ActiveSorting}; +export type { + TableData, + TableRow, + TableColumn, + TableMethods, + TableRow as TableRowData, + TableHandle, + TableProps, + SharedListProps, + CompareItemsCallback, + IsItemInFilterCallback, + IsItemInSearchCallback, + FilterConfig, + ActiveSorting, +}; diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx new file mode 100644 index 000000000000..e6979f5d4117 --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; +import Icon from '@components/Icon'; +import Table from '@components/Table'; +import Text from '@components/Text'; +import TextWithTooltip from '@components/TextWithTooltip'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import {WorkspaceCategoryTableRowData} from '.'; + +type WorkspaceCategoriesTableRowProps = { + item: WorkspaceCategoryTableRowData; + + rowIndex: number; + + shouldShowApproverColumn: boolean; +}; + +export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + + return ( + + {({hovered}) => ( + <> + + {item.name} + + + + {item.glCode} + + + {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..eaa8f92b1b2a --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -0,0 +1,108 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React from 'react'; +import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table/'; +import useLocalize from '@hooks/useLocalize'; +import {AvatarSource} from '@libs/UserAvatarUtils'; +import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; + +export type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; + +export type WorkspaceCategoryTableRowData = TableData & { + name: string; + glCode?: string; + approverAvatar?: AvatarSource; + approverAccountID?: number; + approverDisplayName?: string; + isDisabled: boolean; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; + action: () => void; +}; + +type WorkspaceCategoriesTableProps = { + categories: WorkspaceCategoryTableRowData[]; + + shouldShowApproverColumn: boolean; +}; + +export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn}: WorkspaceCategoriesTableProps) { + const {translate, localeCompare} = useLocalize(); + + const categoryTableColumns: Array> = [ + { + key: 'name', + label: translate('common.name'), + sortable: true, + }, + { + key: 'glCode', + 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: 64, + }, + { + key: 'actions', + label: '', + sortable: false, + width: 52, + }, + ]; + + 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') { + return (item1.isDisabled === item2.isDisabled ? 0 : item1.isDisabled ? 1 : -1) * orderMultiplier; + } + + return localeCompare(item1.name, item2.name) * orderMultiplier; + }; + + const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { + const searchLower = searchValue.toLowerCase(); + return !!item.name.toLowerCase().includes(searchLower) || !!item.glCode?.toLowerCase().includes(searchLower); + }; + + const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + return ( + + + +
+ ); +} diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index bdc2ac9a40e1..b9fc8c897415 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native'; import Button from '@components/Button'; import Icon from '@components/Icon'; import ReportActionAvatars from '@components/ReportActionAvatars'; -import Table from '@components/Table'; +import Table, {TableData} from '@components/Table'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -22,19 +22,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 ed57d777458c..b03621cb39cb 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -148,18 +148,22 @@ function WorkspaceCompanyCardsTable({ { key: 'member', label: translate('common.member'), + sortable: true, }, { key: 'card', label: translate('workspace.companyCards.card'), + sortable: true, }, { key: 'customCardName', label: translate('workspace.companyCards.cardName'), + sortable: true, }, { key: 'actions', label: '', + sortable: false, styling: { containerStyles: [styles.justifyContentEnd, styles.pr3], }, @@ -173,6 +177,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/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index b399ee0ab53b..2bec634b49b9 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -19,6 +19,7 @@ import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import Switch from '@components/Switch'; +import WorkspaceCategoriesTable, {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; @@ -218,9 +219,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const glCodeTextStyle = useMemo(() => [styles.alignSelfStart], [styles.alignSelfStart]); const switchContainerStyle = useMemo(() => [StyleUtils.getMinimumWidth(variables.w72)], [StyleUtils]); - const categoryList = useMemo(() => { + const categoryRows = useMemo(() => { const categories = Object.values(policyCategories ?? {}); - return categories.reduce((acc, value) => { + + return categories.reduce((acc, value, index) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (!isOffline && isDisabled) { @@ -229,81 +231,102 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const approverEmail = shouldShowApproverColumn ? (getCategoryApproverRule(policy?.rules?.approvalRules ?? [], value.name)?.approver ?? '') : ''; const approverPersonalDetail = getPersonalDetailByEmail(approverEmail); - const {avatar, displayName = approverEmail, accountID} = approverPersonalDetail ?? {}; + const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; acc.push({ - text: getDecodedCategoryName(value.name), - keyForList: value.name, + keyForList: `${value.name}-${index}`, + name: getDecodedCategoryName(value.name), + glCode: value['GL Code'], + approverAvatar, + approverAccountID, + approverDisplayName, 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={isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} - /> - - - ) : ( - { - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - updateWorkspaceCategoryEnabled(newValue, value.name); - }} - showLockIcon={isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} - /> - ), + pendingAction: value.pendingAction, + action: () => { + const path = isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, value.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(value.name)); + + Navigation.navigate(path); + }, }); return acc; + + // 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={isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} + // /> + // + // + // ) : ( + // { + // if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { + // showCannotDeleteOrDisableLastCategoryModal(); + // return; + // } + // updateWorkspaceCategoryEnabled(newValue, value.name); + // }} + // showLockIcon={isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])} + // /> + // ), + // }); + + // return acc; }, []); }, [ showCannotDeleteOrDisableLastCategoryModal, @@ -332,9 +355,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [localeCompare], ); - const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryList, filterCategory, sortCategories); + const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryRows, filterCategory, sortCategories); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); + useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); const toggleCategory = useCallback( (category: ListItem) => { @@ -431,7 +454,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategories([]); }; - 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); @@ -692,7 +715,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { {translate('workspace.categories.subtitle')} )} - {categoryList.length > CONST.SEARCH_ITEM_LIMIT && ( + {categoryRows.length > CONST.SEARCH_ITEM_LIMIT && ( )} {hasVisibleCategories && !isLoading && ( - item && toggleCategory(item)} - onSelectAll={filteredCategoryList.length > 0 ? toggleAllCategories : undefined} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - turnOnSelectionModeOnLongPress={isSmallScreenWidth} - customListHeader={getCustomListHeader()} - customListHeaderContent={headerContent} - canSelectMultiple={canSelectMultiple} - selectAllAccessibilityLabel={translate('accessibilityHints.selectAllCategories')} - shouldShowListEmptyContent={false} - onDismissError={dismissError} - showScrollIndicator={false} - shouldHeaderBeInsideList - shouldShowRightCaret + + + // item && toggleCategory(item)} + // onSelectAll={filteredCategoryList.length > 0 ? toggleAllCategories : undefined} + // shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + // turnOnSelectionModeOnLongPress={isSmallScreenWidth} + // customListHeader={getCustomListHeader()} + // customListHeaderContent={headerContent} + // canSelectMultiple={canSelectMultiple} + // selectAllAccessibilityLabel={translate('accessibilityHints.selectAllCategories')} + // shouldShowListEmptyContent={false} + // onDismissError={dismissError} + // showScrollIndicator={false} + // shouldHeaderBeInsideList + // shouldShowRightCaret + // /> )} {!hasVisibleCategories && !isLoading && inputValue.length === 0 && ( diff --git a/src/styles/variables.ts b/src/styles/variables.ts index adf79601a629..53f3a48f421c 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -131,6 +131,7 @@ export default { tableGroupRowPaddingVertical: 4, tableGroupRowHeight: 36, tableSkeletonHeight: 32, + tableCheckboxColumnWidth: 20, sectionMenuItemHeight: 52, sectionMenuItemHeightCompact: 44, optionsListSectionHeaderHeight: getValueUsingPixelRatio(32, 38),