From 9c8b9e261a614fe107c6e4207f4cce3308bbfa35 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 11:09:39 -0400 Subject: [PATCH 01/73] create new folder --- .../WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx | 5 +++++ src/components/Tables/WorkspaceCategoriesTable/index.tsx | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx create mode 100644 src/components/Tables/WorkspaceCategoriesTable/index.tsx diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx new file mode 100644 index 000000000000..97ff54b3622d --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -0,0 +1,5 @@ +type WorkspaceCategoriesTableRowProps = {}; + +export default function WorkspaceCategoriesTableRow({}: WorkspaceCategoriesTableRowProps) { + return <>; +} diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx new file mode 100644 index 000000000000..9e2008ca8b1f --- /dev/null +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -0,0 +1,5 @@ +type WorkspaceCategoriesTableProps = {}; + +export default function WorkspaceCategoriesTable({}: WorkspaceCategoriesTableProps) { + return <>; +} From 4d06dbe9193b0fb13b555bc6b5ab23606619c596 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 11:24:30 -0400 Subject: [PATCH 02/73] setup the component --- .../WorkspaceCategoriesTableRow.tsx | 10 +++- .../Tables/WorkspaceCategoriesTable/index.tsx | 55 ++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 97ff54b3622d..04390ffc0ff8 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -1,5 +1,11 @@ -type WorkspaceCategoriesTableRowProps = {}; +import {WorkspaceCategoryTableRowData} from '.'; -export default function WorkspaceCategoriesTableRow({}: WorkspaceCategoriesTableRowProps) { +type WorkspaceCategoriesTableRowProps = { + item: WorkspaceCategoryTableRowData; + + rowIndex: number; +}; + +export default function WorkspaceCategoriesTableRow({rowIndex, item}: WorkspaceCategoriesTableRowProps) { return <>; } diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 9e2008ca8b1f..b45efe0b9a16 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,5 +1,54 @@ -type WorkspaceCategoriesTableProps = {}; +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import Table, {TableColumn} 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 default function WorkspaceCategoriesTable({}: WorkspaceCategoriesTableProps) { - return <>; +export type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; + +export type WorkspaceCategoryTableRowData = { + name: string; + glCode: string; + approverAvatar?: AvatarSource; + approverDisplayName?: string; + isDisabled: boolean; + errors: OnyxCommon.Errors; + pendingAction: OnyxCommon.PendingAction; +}; + +type WorkspaceCategoriesTableProps = { + categories: WorkspaceCategoryTableRowData[]; + + shouldShowApproverColumn: boolean; +}; + +export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn}: WorkspaceCategoriesTableProps) { + const {translate} = useLocalize(); + + const categoryTableColumns: Array> = [ + {key: 'name', label: translate('common.name')}, + {key: 'glCode', label: translate('workspace.categories.glCode')}, + ...(shouldShowApproverColumn ? [{key: 'approver', label: translate('common.approver')} as const] : []), + {key: 'enabled', label: translate('common.enabled')}, + {key: 'actions', label: ''}, + ]; + + const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( + + ); + + return ( + + + +
+ ); } From c080185d5f7b9038aa0d76b615a8e8646a0ab1c6 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 11:38:56 -0400 Subject: [PATCH 03/73] add the sortihng & comparing --- .../Tables/WorkspaceCategoriesTable/index.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index b45efe0b9a16..ecb9668c1750 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,5 +1,5 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; -import Table, {TableColumn} from '@components/Table/'; +import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn} from '@components/Table/'; import useLocalize from '@hooks/useLocalize'; import {AvatarSource} from '@libs/UserAvatarUtils'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -24,7 +24,7 @@ type WorkspaceCategoriesTableProps = { }; export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn}: WorkspaceCategoriesTableProps) { - const {translate} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const categoryTableColumns: Array> = [ {key: 'name', label: translate('common.name')}, @@ -34,6 +34,27 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover {key: 'actions', label: ''}, ]; + 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) => ( From 5d6d393c2030fd410f93542a3347fc42cfc07b7e Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 11:54:46 -0400 Subject: [PATCH 04/73] add row data --- .../WorkspaceCategoriesTableRow.tsx | 33 +++++++++++++++++-- .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 04390ffc0ff8..1b8fca828a47 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -1,11 +1,40 @@ +import {View} from 'react-native'; +import Table from '@components/Table'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; import {WorkspaceCategoryTableRowData} from '.'; type WorkspaceCategoriesTableRowProps = { item: WorkspaceCategoryTableRowData; rowIndex: number; + + shouldShowApproverColumn: boolean; }; -export default function WorkspaceCategoriesTableRow({rowIndex, item}: WorkspaceCategoriesTableRowProps) { - return <>; +export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + return ( + + {({hovered}) => ( + <> + + + + + {shouldShowApproverColumn && } + + + + )} + + ); } diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index ecb9668c1750..8912d682f362 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -15,6 +15,7 @@ export type WorkspaceCategoryTableRowData = { isDisabled: boolean; errors: OnyxCommon.Errors; pendingAction: OnyxCommon.PendingAction; + action: () => void; }; type WorkspaceCategoriesTableProps = { From d8f2b3a45393001de29f4497a615f5c15c0bd897 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 12:29:30 -0400 Subject: [PATCH 05/73] build the category items --- .../WorkspaceCategoriesTableRow.tsx | 1 + .../Tables/WorkspaceCategoriesTable/index.tsx | 10 +- .../categories/WorkspaceCategoriesPage.tsx | 210 ++++++++++-------- 3 files changed, 127 insertions(+), 94 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 1b8fca828a47..a27d5f06c327 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import {View} from 'react-native'; import Table from '@components/Table'; import useTheme from '@hooks/useTheme'; diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 8912d682f362..913f8be37e66 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,4 +1,5 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React from 'react'; import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn} from '@components/Table/'; import useLocalize from '@hooks/useLocalize'; import {AvatarSource} from '@libs/UserAvatarUtils'; @@ -8,13 +9,15 @@ import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; export type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; export type WorkspaceCategoryTableRowData = { + keyForList: string; name: string; - glCode: string; + glCode?: string; approverAvatar?: AvatarSource; + approverAccountID?: number; approverDisplayName?: string; isDisabled: boolean; - errors: OnyxCommon.Errors; - pendingAction: OnyxCommon.PendingAction; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; action: () => void; }; @@ -60,6 +63,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index e9b6c1280aed..81cd5e8c7348 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -20,6 +20,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'; @@ -219,9 +220,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) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (!isOffline && isDisabled) { @@ -230,81 +232,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, + 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) + : ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyId, 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, @@ -333,9 +356,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) => { @@ -434,7 +457,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); @@ -695,7 +718,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 && ( From 05da384e7d53ba85aa96802e088fffdec43d2fc4 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 12:43:54 -0400 Subject: [PATCH 06/73] add more properties to the table header --- src/components/Table/TableHeader.tsx | 6 ++- src/components/Table/types.ts | 6 +++ .../Tables/WorkspaceCategoriesTable/index.tsx | 43 ++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index b0d94614b6bc..67ee4fa2e4f3 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -62,6 +62,8 @@ function TableHeader({style, shouldHideHea return null; } + const gridTemplateColumns = columns.map((column) => column.width ?? '1fr').join(' '); + 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}, style, ]} // eslint-disable-next-line react/jsx-props-no-spreading @@ -148,6 +150,7 @@ function TableHeaderColumn({column}: {colu styles.tableHeaderContentHeight, column.styling?.flex ? {flex: column.styling.flex} : styles.flex1, column.styling?.containerStyles, + !column.sortable && styles.cursorDefault, ]; return ( @@ -155,6 +158,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/types.ts b/src/components/Table/types.ts index 39f32d7e6359..40c332be9690 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -31,6 +31,12 @@ 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; }; diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 913f8be37e66..cd19ae85615b 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -6,7 +6,7 @@ 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 WorkspaceCategoryTableColumnKey = 'selection' | 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; export type WorkspaceCategoryTableRowData = { keyForList: string; @@ -31,11 +31,40 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover const {translate, localeCompare} = useLocalize(); const categoryTableColumns: Array> = [ - {key: 'name', label: translate('common.name')}, - {key: 'glCode', label: translate('workspace.categories.glCode')}, - ...(shouldShowApproverColumn ? [{key: 'approver', label: translate('common.approver')} as const] : []), - {key: 'enabled', label: translate('common.enabled')}, - {key: 'actions', label: ''}, + { + key: 'selection', + label: '', + sortable: false, + }, + { + 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, + }, + { + key: 'actions', + label: '', + sortable: false, + }, ]; const compareItems: CompareItemsCallback = (item1, item2, activeSorting) => { @@ -56,7 +85,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover const isItemInSearch: IsItemInSearchCallback = (item, searchValue) => { const searchLower = searchValue.toLowerCase(); - return item.name.toLowerCase().includes(searchLower) || item.glCode.toLowerCase().includes(searchLower); + return !!item.name.toLowerCase().includes(searchLower) || !!item.glCode?.toLowerCase().includes(searchLower); }; const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( From dc9532ab0b87f432857e92fcca352cf9be82a45c Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 13:16:15 -0400 Subject: [PATCH 07/73] fix grid columns and add ne wheaderprosp --- src/components/Table/TableHeader.tsx | 2 +- src/components/Table/TableRow.tsx | 4 ++-- .../WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx | 5 +++++ src/components/Tables/WorkspaceCategoriesTable/index.tsx | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 67ee4fa2e4f3..cb406c738f8c 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -62,7 +62,7 @@ function TableHeader({style, shouldHideHea return null; } - const gridTemplateColumns = columns.map((column) => column.width ?? '1fr').join(' '); + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')).join(' '); return ( (column.width ? `${column.width}px` : '1fr')).join(' '); const tableRowPressableStyles = [ styles.mh5, @@ -81,7 +81,7 @@ 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}], ]; const renderChildren = (state: PressableStateCallbackType) => { diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index a27d5f06c327..ad039fb388e3 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {View} from 'react-native'; import Table from '@components/Table'; +import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {WorkspaceCategoryTableRowData} from '.'; @@ -29,6 +30,10 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApprove <> + + {item.name} + + {shouldShowApproverColumn && } diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index cd19ae85615b..ece4d62f1abb 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -35,6 +35,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover key: 'selection', label: '', sortable: false, + width: 52, }, { key: 'name', @@ -59,11 +60,13 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover key: 'enabled', label: translate('common.enabled'), sortable: true, + width: 64, }, { key: 'actions', label: '', sortable: false, + width: 52, }, ]; From 9aa5b23f7e9520978e4cf47e6f18faecd69b0ab6 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 13:23:41 -0400 Subject: [PATCH 08/73] add the row content --- src/components/Table/TableBody.tsx | 1 + .../WorkspaceCategoriesTableRow.tsx | 37 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 76587f776fc2..cf87afeacbc2 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -84,6 +84,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/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index ad039fb388e3..2285f7960f81 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -1,9 +1,15 @@ 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 = { @@ -17,6 +23,7 @@ type WorkspaceCategoriesTableRowProps = { export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); return ( {item.name} - + + {item.glCode} + - {shouldShowApproverColumn && } + {shouldShowApproverColumn && ( + + {item.approverDisplayName && item.approverAccountID && ( + <> + + + + )} + + )} + + + + )} From 47ea4f0cbfe944f3fdc2db06a332d76681828256 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 13 May 2026 13:38:06 -0400 Subject: [PATCH 09/73] add selection mode --- src/components/Table/Table.tsx | 2 ++ src/components/Table/TableContext.tsx | 3 +++ src/components/Table/TableHeader.tsx | 10 +++++--- src/components/Table/TableRow.tsx | 24 +++++++++++++++---- src/components/Table/types.ts | 3 +++ .../WorkspaceCategoriesTableRow.tsx | 2 -- .../Tables/WorkspaceCategoriesTable/index.tsx | 9 ++----- 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 8c280a8c6cfc..413ec53fc269 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -140,6 +140,7 @@ function Table) { if (!columns || columns.length === 0) { @@ -200,6 +201,7 @@ function Table; + /** Whether or not selection is enabled for the table */ + selectionEnabled?: boolean; + /** The data array after filtering, searching, and sorting have been applied. */ processedData: T[]; diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index cb406c738f8c..9c637c9a40bf 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -52,7 +52,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,7 +62,11 @@ function TableHeader({style, shouldHideHea 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 (selectionEnabled) { + gridTemplateColumns.unshift('64px'); + } return ( ({style, shouldHideHea styles.gap3, // Use Grid on web when available (will override flex if supported) styles.dGrid, - !shouldUseNarrowTableLayout && {gridTemplateColumns}, + !shouldUseNarrowTableLayout && {gridTemplateColumns: gridTemplateColumns.join(' ')}, style, ]} // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index d7dedf13e3ad..4afe2080e850 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 {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'; @@ -53,12 +54,16 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, selectionEnabled} = useTableContext(); const rowCount = processedData.length; const isLastRow = rowIndex === rowCount - 1; const isInteractive = interactive && !isLoading; - const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')).join(' '); + const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + + if (selectionEnabled) { + gridTemplateColumns.unshift('64px'); + } const tableRowPressableStyles = [ styles.mh5, @@ -81,7 +86,7 @@ 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 renderChildren = (state: PressableStateCallbackType) => { @@ -124,7 +129,18 @@ export default function TableRow({ ) : ( - {renderChildren(state)} + + {selectionEnabled && ( + + {}} + /> + + )} + + {renderChildren(state)} + ) } diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 40c332be9690..636ad549ef19 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -117,6 +117,9 @@ type TableProps>; diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 2285f7960f81..e6979f5d4117 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -35,8 +35,6 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApprove > {({hovered}) => ( <> - - {item.name} diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index ece4d62f1abb..8b3bd9798825 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -6,7 +6,7 @@ import {AvatarSource} from '@libs/UserAvatarUtils'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; -export type WorkspaceCategoryTableColumnKey = 'selection' | 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; +export type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; export type WorkspaceCategoryTableRowData = { keyForList: string; @@ -31,12 +31,6 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover const {translate, localeCompare} = useLocalize(); const categoryTableColumns: Array> = [ - { - key: 'selection', - label: '', - sortable: false, - width: 52, - }, { key: 'name', label: translate('common.name'), @@ -101,6 +95,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover return ( Date: Thu, 14 May 2026 07:47:04 -0400 Subject: [PATCH 10/73] add checkboxes --- src/components/Table/TableHeader.tsx | 34 ++++++++++++++++++++-------- src/components/Table/TableRow.tsx | 2 +- src/styles/variables.ts | 1 + 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 9c637c9a40bf..d5425fe26ee4 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'; @@ -65,7 +66,7 @@ function TableHeader({style, shouldHideHea const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); if (selectionEnabled) { - gridTemplateColumns.unshift('64px'); + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); } return ( @@ -100,15 +101,28 @@ function TableHeader({style, shouldHideHea )} - {!shouldUseNarrowTableLayout && - columns.map((column) => { - return ( - - ); - })} + {!shouldUseNarrowTableLayout && ( + <> + {selectionEnabled && ( + + {}} + /> + + )} + + {columns.map((column) => { + return ( + + ); + })} + + )} ); } diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 22904b11d13f..2e3c7580811f 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -63,7 +63,7 @@ export default function TableRow({ const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); if (selectionEnabled) { - gridTemplateColumns.unshift('64px'); + gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); } const tableRowPressableStyles = [ diff --git a/src/styles/variables.ts b/src/styles/variables.ts index f1791dee9512..64be4a2d434d 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), From 7cdf97d5c0536e551539804fdb2a81745062327e Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 07:08:42 -0400 Subject: [PATCH 11/73] update table data --- src/components/Table/Table.tsx | 14 +++++++++----- src/components/Table/types.ts | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 413ec53fc269..502ee1058d79 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,12 +1,12 @@ import type {FlashListRef} from '@shopify/flash-list'; -import React, {useImperativeHandle, useRef} from 'react'; +import React, {useImperativeHandle, useRef, useState} from 'react'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useFiltering from './middlewares/filtering'; import useSearching from './middlewares/searching'; import useSorting from './middlewares/sorting'; import TableContext from './TableContext'; import type {TableContextValue} from './TableContext'; -import type {TableHandle, TableMethods, TableProps} from './types'; +import type {TableHandle, TableMethods, TableProps, TableRowData} from './types'; /** * A composable table component that provides filtering, search, and sorting functionality. @@ -129,7 +129,7 @@ import type {TableHandle, TableMethods, TableProps} from './types'; *
* ``` */ -function Table({ +function Table({ ref, title, data = [], @@ -143,6 +143,10 @@ function Table) { + const [tableData, setTableData] = useState[]>(() => { + return data.map((item, index) => ({...item, checked: false})); + }); + if (!columns || columns.length === 0) { throw new Error('Table columns must be provided'); } @@ -155,7 +159,7 @@ function Table({compareItems, initialSortColumn}); - const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), data); + const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), tableData); const listRef = useRef>(null); @@ -181,7 +185,7 @@ function Table; }); - const originalDataLength = data?.length ?? 0; + const originalDataLength = tableData?.length ?? 0; const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; // Check if filters are applied (not default values) diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 6b7528d0577c..c1375daad50e 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -41,6 +41,10 @@ type TableColumn = { styling?: TableColumnStyling; }; +type TableRowData = T & { + selected?: boolean; +}; + /** * Methods exposed by the Table component for programmatic control. * Combines sorting, filtering, and searching capabilities. @@ -144,4 +148,16 @@ type TableProps>; }>; -export type {TableColumn, TableMethods, TableHandle, TableProps, SharedListProps, CompareItemsCallback, IsItemInFilterCallback, IsItemInSearchCallback, FilterConfig, ActiveSorting}; +export type { + TableColumn, + TableMethods, + TableRowData, + TableHandle, + TableProps, + SharedListProps, + CompareItemsCallback, + IsItemInFilterCallback, + IsItemInSearchCallback, + FilterConfig, + ActiveSorting, +}; From 3c9e62fb37cbd859232078b0b43d3d0670fd34b4 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 12:46:45 -0400 Subject: [PATCH 12/73] add more selection logic --- src/components/Table/Table.tsx | 44 ++++++++++++++----- src/components/Table/TableContext.tsx | 21 +++++---- src/components/Table/TableRow.tsx | 12 +++-- src/components/Table/middlewares/filtering.ts | 32 +++++++------- src/components/Table/middlewares/selection.ts | 10 +++++ src/components/Table/types.ts | 31 +++++++------ 6 files changed, 92 insertions(+), 58 deletions(-) create mode 100644 src/components/Table/middlewares/selection.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 502ee1058d79..721c086713ec 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -129,7 +129,7 @@ import type {TableHandle, TableMethods, TableProps, TableRowData} from './types' * * ``` */ -function Table({ +function Table({ ref, title, data = [], @@ -142,9 +142,13 @@ function Table) { - const [tableData, setTableData] = useState[]>(() => { - return data.map((item, index) => ({...item, checked: false})); +}: TableProps) { + const [tableData, setTableData] = useState[]>(() => { + return data.map((item, index) => ({ + ...item, + selected: false, + rowKey: index.toString(), + })); }); if (!columns || columns.length === 0) { @@ -153,15 +157,15 @@ function Table({filters, isItemInFilter}); + const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering, FilterKey>({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, ColumnKey>({compareItems, initialSortColumn}); const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), tableData); - const listRef = useRef>(null); + const listRef = useRef>(null); const tableMethods: TableMethods = { ...filterMethods, @@ -180,9 +184,9 @@ function Table]; + return listRef.current?.[property as keyof FlashListRef]; }, - }) as TableHandle; + }) as TableHandle; }); const originalDataLength = tableData?.length ?? 0; @@ -200,8 +204,23 @@ function Table 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters); + /** + * + */ + const handleRowSelection = (rowKey: string) => { + setTableData((prevData) => { + return prevData.map((item) => { + if (item.rowKey === rowKey) { + return {...item, selected: !item.selected}; + } + + return item; + }); + }); + }; + // eslint-disable-next-line react/jsx-no-constructed-context-values - const contextValue: TableContextValue = { + const contextValue: TableContextValue = { title, listRef, listProps, @@ -216,11 +235,12 @@ function Table}>{children}; + return }>{children}; } export default Table; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index ac2961417dd6..f7d61ddc9460 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -2,30 +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, 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; @@ -57,11 +57,13 @@ type TableContextValue void; + /** Whether to use a narrow layout (e.g. on mobile screens). */ shouldUseNarrowTableLayout: boolean; }; -const defaultTableContextValue: TableContextValue = { +const defaultTableContextValue: TableContextValue = { listRef: React.createRef(), processedData: [], originalDataLength: 0, @@ -75,6 +77,7 @@ const defaultTableContextValue: TableContextValue = { tableMethods: {} as TableMethods, filterConfig: undefined, listProps: {} as SharedListProps, + handleRowSelection: () => {}, hasActiveFilters: false, hasSearchString: false, isEmptyResult: false, @@ -100,14 +103,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/TableRow.tsx b/src/components/Table/TableRow.tsx index a3097ee151bd..ea39402a39fd 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -55,8 +55,9 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout, selectionEnabled} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, handleRowSelection, selectionEnabled} = useTableContext(); + const item = processedData[rowIndex]; const rowCount = processedData.length; const isLastRow = rowIndex === rowCount - 1; const isInteractive = interactive && !isLoading; @@ -98,11 +99,7 @@ export default function TableRow({ }; return ( - + {}} + onPress={() => handleRowSelection(item.rowKey)} /> )} diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 84a01bbd77e4..690269c2de1a 100644 --- a/src/components/Table/middlewares/filtering.ts +++ b/src/components/Table/middlewares/filtering.ts @@ -23,12 +23,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 +46,34 @@ 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 +97,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 +110,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 +131,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..8deb492c3b05 --- /dev/null +++ b/src/components/Table/middlewares/selection.ts @@ -0,0 +1,10 @@ +import {Middleware} from './types'; + +export default function useSelection() { + const middleware: Middleware = (data) => { + // Return filtered data; + return data; + }; + + return {middleware}; +} diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index c1375daad50e..07dbaff70f3f 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -41,15 +41,18 @@ type TableColumn = { styling?: TableColumnStyling; }; -type TableRowData = T & { - selected?: boolean; +type TableRowData = DataType & { + /** The key for the row */ + rowKey: string; + + /** 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. */ @@ -59,18 +62,18 @@ type TableMethods = 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. @@ -79,7 +82,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 +102,13 @@ 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: TableRowData[] | undefined; /** Whether multi selection is enabled */ selectionEnabled?: boolean; @@ -130,22 +133,22 @@ 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 { From 33d738ff795934d78704790442dfeac63e2b79fc Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 12:54:30 -0400 Subject: [PATCH 13/73] update selection state for table row --- src/components/Table/TableRow.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index ea39402a39fd..fde198480fa8 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -77,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, ]; @@ -90,6 +91,16 @@ export default function TableRow({ !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); @@ -106,8 +117,8 @@ export default function TableRow({ style={tableRowPressableStyles} sentryLabel={sentryLabel} interactive={isInteractive} + hoverStyle={tableRowPressableHoverStyle} pressDimmingValue={isInteractive ? undefined : 1} - hoverStyle={isInteractive && styles.hoveredComponentBG} role={isInteractive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} onPress={onPress} {...props} From ed97bf1d007761df499b467790c5a273488e93a2 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 13:35:54 -0400 Subject: [PATCH 14/73] shift click logic --- src/components/Table/Table.tsx | 47 ++++++++++++++++++- src/components/Table/TableContext.tsx | 5 +- src/components/Table/TableRow.tsx | 32 ++++++++----- src/components/Table/middlewares/selection.ts | 10 ---- .../categories/WorkspaceCategoriesPage.tsx | 2 +- 5 files changed, 72 insertions(+), 24 deletions(-) delete mode 100644 src/components/Table/middlewares/selection.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 721c086713ec..e489ef79df35 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -143,6 +143,9 @@ function Table) { + const lastSelectedRowBooleanRef = useRef(false); + const lastSelectedRowKeyRef = useRef(null); + const [tableData, setTableData] = useState[]>(() => { return data.map((item, index) => ({ ...item, @@ -204,14 +207,55 @@ function Table 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters); + const handleShiftRowSelection = (rowKey: string) => { + const rowKeyExists = processedData.some((item) => item.rowKey === rowKey); + + if (!rowKeyExists) { + return; + } + + const lastSelectedKey = lastSelectedRowKeyRef.current; + + if (!lastSelectedKey) { + handleRowSelection(rowKey); + return; + } + + const rowKeys = processedData.map((item) => item.rowKey); + const lastIndex = rowKeys.indexOf(lastSelectedKey); + const currentIndex = rowKeys.indexOf(rowKey); + + if (lastIndex === -1 || currentIndex === -1) { + handleRowSelection(rowKey); + return; + } + + const [start, end] = currentIndex > lastIndex ? [lastIndex, currentIndex] : [currentIndex, lastIndex]; + const newSelectedState = lastSelectedRowBooleanRef.current; + + setTableData((prevData) => { + return prevData.map((item) => { + if (rowKeys.indexOf(item.rowKey) >= start && rowKeys.indexOf(item.rowKey) <= end) { + return {...item, selected: newSelectedState}; + } + + return item; + }); + }); + }; + /** * */ + // JACK_TODO: going to clean this up const handleRowSelection = (rowKey: string) => { setTableData((prevData) => { return prevData.map((item) => { if (item.rowKey === rowKey) { - return {...item, selected: !item.selected}; + const selected = !item.selected; + lastSelectedRowKeyRef.current = rowKey; + lastSelectedRowBooleanRef.current = selected; + return {...item, selected}; } return item; @@ -235,6 +279,7 @@ function Table void; + handleRowSelection: (rowKey: string) => void; /** Whether to use a narrow layout (e.g. on mobile screens). */ @@ -77,11 +79,12 @@ const defaultTableContextValue: TableContextValue = { tableMethods: {} as TableMethods, filterConfig: undefined, listProps: {} as SharedListProps, - handleRowSelection: () => {}, hasActiveFilters: false, hasSearchString: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, + handleShiftRowSelection: () => {}, + handleRowSelection: () => {}, }; const TableContext = createContext(defaultTableContextValue); diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index fde198480fa8..2139af4b3b26 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -55,7 +55,7 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout, handleRowSelection, selectionEnabled} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, handleRowSelection, handleShiftRowSelection, selectionEnabled} = useTableContext(); const item = processedData[rowIndex]; const rowCount = processedData.length; @@ -109,6 +109,25 @@ export default function TableRow({ return children; }; + const CheckboxComponent = ( + + { + const webEvent = event as unknown as MouseEvent; + + if (webEvent && webEvent.shiftKey) { + handleShiftRowSelection(item.rowKey); + return; + } + + handleRowSelection(item.rowKey); + }} + /> + + ); + return ( ) : ( - {selectionEnabled && ( - - handleRowSelection(item.rowKey)} - /> - - )} - + {selectionEnabled && CheckboxComponent} {renderChildren(state)} ) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts deleted file mode 100644 index 8deb492c3b05..000000000000 --- a/src/components/Table/middlewares/selection.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {Middleware} from './types'; - -export default function useSelection() { - const middleware: Middleware = (data) => { - // Return filtered data; - return data; - }; - - return {middleware}; -} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index cb1f7ba15c10..eb627ef5aaac 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -247,7 +247,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { action: () => { const path = isQuickSettingsFlow ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, value.name, backTo) - : ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(policyId, value.name); + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(value.name)); Navigation.navigate(path); }, From a413425437e2f433a08000f35b5b616957c1ac2a Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 14:12:49 -0400 Subject: [PATCH 15/73] add data type type --- src/components/Table/Table.tsx | 4 ++-- src/components/Table/TableContext.tsx | 10 +++++----- src/components/Table/middlewares/filtering.ts | 16 ++++++++++------ src/components/Table/middlewares/selection.ts | 12 ++++++++++++ src/components/Table/types.ts | 18 ++++++++++++------ 5 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 src/components/Table/middlewares/selection.ts diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index e489ef79df35..a420bf145c10 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -6,7 +6,7 @@ import useSearching from './middlewares/searching'; import useSorting from './middlewares/sorting'; import TableContext from './TableContext'; import type {TableContextValue} from './TableContext'; -import type {TableHandle, TableMethods, TableProps, TableRowData} from './types'; +import type {TableData, TableHandle, TableMethods, TableProps, TableRow, TableRowData} from './types'; /** * A composable table component that provides filtering, search, and sorting functionality. @@ -129,7 +129,7 @@ import type {TableHandle, TableMethods, TableProps, TableRowData} from './types' * * ``` */ -function Table({ +function Table({ ref, title, data = [], diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index c592212fdb42..7dc1f14209a3 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -2,7 +2,7 @@ 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, TableRowData} from './types'; +import type {SharedListProps, TableColumn, TableData, TableMethods, TableRowData} from './types'; /** * The shape of the Table context value. @@ -11,7 +11,7 @@ import type {SharedListProps, TableColumn, TableMethods, TableRowData} from './t * @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; @@ -65,7 +65,7 @@ type TableContextValue = { +const defaultTableContextValue: TableContextValue = { listRef: React.createRef(), processedData: [], originalDataLength: 0, @@ -78,7 +78,7 @@ const defaultTableContextValue: TableContextValue = { activeSearchString: '', tableMethods: {} as TableMethods, filterConfig: undefined, - listProps: {} as SharedListProps, + listProps: {} as SharedListProps, hasActiveFilters: false, hasSearchString: false, isEmptyResult: false, @@ -106,7 +106,7 @@ const TableContext = createContext(defaultTableContextValue); * } * ``` */ -function useTableContext() { +function useTableContext() { const context = useContext(TableContext); if (context === defaultTableContextValue && context.activeFilters === undefined) { diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 690269c2de1a..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'; /** @@ -28,7 +29,7 @@ type FilterConfig = Record = (item: DataType, filters: string[]) => boolean; +type IsItemInFilterCallback = (item: DataType, filters: string[]) => boolean; /** * Methods exposed by the table to control filtering. @@ -49,7 +50,7 @@ type FilteringMethods = { * @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; }; @@ -60,7 +61,7 @@ type UseFilteringProps = { * @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; }; @@ -73,7 +74,10 @@ type UseFilteringResult = Middlewar * @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; @@ -113,7 +117,7 @@ function useFiltering({filters, isI * @template DataType - The type of items in the data array. * @template FilterKey - The type of filter keys. */ -type FilteringMiddlewareParams = { +type FilteringMiddlewareParams = { data: DataType[]; filters?: FilterConfig; currentFilters: Record; @@ -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): DataType[] { +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..c208c454714e --- /dev/null +++ b/src/components/Table/middlewares/selection.ts @@ -0,0 +1,12 @@ +import {useState} from 'react'; +import {TableData} from '../types'; + +export default function useSelection() { + const [selectedKeys, setSelectedKeys] = useState([]); + + const middleware = (data: DataType[]) => { + return data.map((item) => ({...item, selected: selectedKeys.includes(item.rowKey)})); + }; + + return {}; +} diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 07dbaff70f3f..9cadb90d37d8 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -5,6 +5,10 @@ import type {FilterConfig, FilteringMethods, IsItemInFilterCallback} from './mid import type {IsItemInSearchCallback, SearchingMethods} from './middlewares/searching'; import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middlewares/sorting'; +type TableData = { + rowKey: string; +}; + /** * Styling options for a table column. */ @@ -41,7 +45,7 @@ type TableColumn = { styling?: TableColumnStyling; }; -type TableRowData = DataType & { +type TableRow = DataType & { /** The key for the row */ rowKey: string; @@ -66,14 +70,14 @@ type TableMethods = FlashListRef & TableMethods; +type TableHandle = FlashListRef & TableMethods; /** * FlashList props with the 'data' prop omitted, as the Table manages data internally. * * @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. @@ -102,13 +106,13 @@ 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: TableRowData[] | undefined; + data: TableRow[] | undefined; /** Whether multi selection is enabled */ selectionEnabled?: boolean; @@ -152,9 +156,11 @@ type TableProps; export type { + TableData, + TableRow, TableColumn, TableMethods, - TableRowData, + TableRow as TableRowData, TableHandle, TableProps, SharedListProps, From 2691534996b8078be6eecbb9eb35be08038cc07c Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 14:39:26 -0400 Subject: [PATCH 16/73] add selection middleware --- src/components/Table/Table.tsx | 83 ++-------------- src/components/Table/TableContext.tsx | 4 - src/components/Table/middlewares/selection.ts | 99 ++++++++++++++++++- src/components/Table/types.ts | 6 +- 4 files changed, 107 insertions(+), 85 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index a420bf145c10..9e80d1478ead 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -3,6 +3,7 @@ import React, {useImperativeHandle, useRef, useState} 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'; @@ -143,30 +144,21 @@ function Table) { - const lastSelectedRowBooleanRef = useRef(false); - const lastSelectedRowKeyRef = useRef(null); - - const [tableData, setTableData] = useState[]>(() => { - return data.map((item, index) => ({ - ...item, - selected: false, - rowKey: index.toString(), - })); - }); - if (!columns || columns.length === 0) { throw new Error('Table columns must be provided'); } const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const {middleware: filterMiddleware, currentFilters, methods: filterMethods} = useFiltering, FilterKey>({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, ColumnKey>({compareItems, initialSortColumn}); + const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); - const processedData = [filterMiddleware, searchMiddleware, sortMiddleware].reduce((acc, middleware) => middleware(acc), tableData); + const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data}); + + const processedData = [filterMiddleware, searchMiddleware, sortMiddleware, selectionMiddleware].reduce((acc, middleware) => middleware(acc), data); const listRef = useRef>(null); @@ -174,6 +166,7 @@ function Table; }); - const originalDataLength = tableData?.length ?? 0; + const originalDataLength = data?.length ?? 0; const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; // Check if filters are applied (not default values) @@ -207,62 +200,6 @@ function Table 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters); - const handleShiftRowSelection = (rowKey: string) => { - const rowKeyExists = processedData.some((item) => item.rowKey === rowKey); - - if (!rowKeyExists) { - return; - } - - const lastSelectedKey = lastSelectedRowKeyRef.current; - - if (!lastSelectedKey) { - handleRowSelection(rowKey); - return; - } - - const rowKeys = processedData.map((item) => item.rowKey); - const lastIndex = rowKeys.indexOf(lastSelectedKey); - const currentIndex = rowKeys.indexOf(rowKey); - - if (lastIndex === -1 || currentIndex === -1) { - handleRowSelection(rowKey); - return; - } - - const [start, end] = currentIndex > lastIndex ? [lastIndex, currentIndex] : [currentIndex, lastIndex]; - const newSelectedState = lastSelectedRowBooleanRef.current; - - setTableData((prevData) => { - return prevData.map((item) => { - if (rowKeys.indexOf(item.rowKey) >= start && rowKeys.indexOf(item.rowKey) <= end) { - return {...item, selected: newSelectedState}; - } - - return item; - }); - }); - }; - - /** - * - */ - // JACK_TODO: going to clean this up - const handleRowSelection = (rowKey: string) => { - setTableData((prevData) => { - return prevData.map((item) => { - if (item.rowKey === rowKey) { - const selected = !item.selected; - lastSelectedRowKeyRef.current = rowKey; - lastSelectedRowBooleanRef.current = selected; - return {...item, selected}; - } - - return item; - }); - }); - }; - // eslint-disable-next-line react/jsx-no-constructed-context-values const contextValue: TableContextValue = { title, @@ -279,8 +216,6 @@ function Table void; - - handleRowSelection: (rowKey: string) => void; - /** Whether to use a narrow layout (e.g. on mobile screens). */ shouldUseNarrowTableLayout: boolean; }; diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index c208c454714e..51dc7b563b3b 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,12 +1,105 @@ -import {useState} from 'react'; +import {useRef, useState} from 'react'; import {TableData} from '../types'; +import {MiddlewareHookResult} from './types'; -export default function useSelection() { +export type UseSelectionProps = { + data: DataType[]; +}; + +export type SelectionMethods = { + handleSelectAll: () => void; + + handleMultipleRowSelection: (rowKey: string) => void; + + handleSingleRowSelection: (rowKey: 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 = (rowKey: string) => { + const rowKeys = data.map((item) => item.rowKey); + const rowKeyExists = rowKeys.includes(rowKey); + + if (!rowKeyExists) { + return; + } + + const lastSelectedRowKey = lastSelectedRowKeyRef.current; + const lastSelectedRowIsSelected = lastSelectedRowIsSelectedRef.current; + + if (!lastSelectedRowKey) { + handleSingleRowSelection(rowKey); + return; + } + + const currentSelectedRowIndex = rowKeys.indexOf(rowKey); + const lastSelectedRowIndex = rowKeys.indexOf(lastSelectedRowKey); + + if (currentSelectedRowIndex === -1 || lastSelectedRowIndex === -1) { + handleSingleRowSelection(rowKey); + 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 = rowKeys[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 = (rowKey: string) => { + setSelectedKeys((prevSelectedKeys) => { + if (prevSelectedKeys.includes(rowKey)) { + return prevSelectedKeys.filter((key) => key !== rowKey); + } else { + return [...prevSelectedKeys, rowKey]; + } + }); + }; + const middleware = (data: DataType[]) => { return data.map((item) => ({...item, selected: selectedKeys.includes(item.rowKey)})); }; - return {}; + return { + middleware, + methods: { + handleSelectAll, + handleMultipleRowSelection, + handleSingleRowSelection, + }, + }; } diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 9cadb90d37d8..6b94c6754d9d 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -3,6 +3,7 @@ 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 = { @@ -46,9 +47,6 @@ type TableColumn = { }; type TableRow = DataType & { - /** The key for the row */ - rowKey: string; - /** Whether or not the row is selected or not */ selected: boolean; }; @@ -60,7 +58,7 @@ type TableRow = DataType & { * @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. From 9edf6925bc58a986d3c3abc2442247c95d67be89 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 14:56:33 -0400 Subject: [PATCH 17/73] update middleware types --- src/components/Table/Table.tsx | 9 +++++++-- src/components/Table/middlewares/selection.ts | 4 ++-- src/components/Table/middlewares/types.ts | 10 +++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 9e80d1478ead..911e4c7701e4 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -33,12 +33,13 @@ import type {TableData, TableHandle, TableMethods, TableProps, TableRow, TableRo * 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 * @@ -158,7 +159,11 @@ function Table({data}); - const processedData = [filterMiddleware, searchMiddleware, sortMiddleware, selectionMiddleware].reduce((acc, middleware) => middleware(acc), data); + // Apply the middleware + const filteredData = filterMiddleware(data); + const searchedData = searchMiddleware(filteredData); + const sortedData = sortMiddleware(searchedData); + const processedData = selectionMiddleware(sortedData); const listRef = useRef>(null); diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 51dc7b563b3b..a9b10ca0d860 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,5 +1,5 @@ import {useRef, useState} from 'react'; -import {TableData} from '../types'; +import {TableData, TableRow} from '../types'; import {MiddlewareHookResult} from './types'; export type UseSelectionProps = { @@ -14,7 +14,7 @@ export type SelectionMethods = { handleSingleRowSelection: (rowKey: string) => void; }; -export type UseSelectionResult = MiddlewareHookResult; +export type UseSelectionResult = MiddlewareHookResult>; export default function useSelection({data}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); 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; From 1206ed946016a94a78391c1894642955dc484643 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 15 May 2026 15:16:53 -0400 Subject: [PATCH 18/73] update table component to use new props --- src/components/Table/Table.tsx | 4 +- src/components/Table/TableRow.tsx | 40 +++++++++---------- src/components/Table/middlewares/selection.ts | 32 +++++++-------- src/components/Table/types.ts | 4 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 5 +-- .../WorkspaceCompanyCardsTableRow.tsx | 23 ++++++----- .../WorkspaceCompanyCardsTable/index.tsx | 5 +++ .../categories/WorkspaceCategoriesPage.tsx | 4 +- 8 files changed, 60 insertions(+), 57 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 911e4c7701e4..cf7c3170ea6d 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,5 +1,5 @@ import type {FlashListRef} from '@shopify/flash-list'; -import React, {useImperativeHandle, useRef, useState} from 'react'; +import React, {useImperativeHandle, useRef} from 'react'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useFiltering from './middlewares/filtering'; import useSearching from './middlewares/searching'; @@ -7,7 +7,7 @@ import useSelection from './middlewares/selection'; import useSorting from './middlewares/sorting'; import TableContext from './TableContext'; import type {TableContextValue} from './TableContext'; -import type {TableData, TableHandle, TableMethods, TableProps, TableRow, TableRowData} from './types'; +import type {TableData, TableHandle, TableMethods, TableProps} from './types'; /** * A composable table component that provides filtering, search, and sorting functionality. diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 2139af4b3b26..6ccb623e9569 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -1,5 +1,5 @@ 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'; @@ -55,7 +55,7 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); - const {processedData, columns, shouldUseNarrowTableLayout, handleRowSelection, handleShiftRowSelection, selectionEnabled} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled} = useTableContext(); const item = processedData[rowIndex]; const rowCount = processedData.length; @@ -109,24 +109,14 @@ export default function TableRow({ return children; }; - const CheckboxComponent = ( - - { - const webEvent = event as unknown as MouseEvent; - - if (webEvent && webEvent.shiftKey) { - handleShiftRowSelection(item.rowKey); - return; - } - - handleRowSelection(item.rowKey); - }} - /> - - ); + const handleCheckboxPress = (event?: MouseEvent) => { + if (event && event.shiftKey) { + tableMethods.handleMultipleRowSelection(item.keyForList); + return; + } + + tableMethods.handleSingleRowSelection(item.keyForList); + }; return ( @@ -156,7 +146,15 @@ export default function TableRow({
) : ( - {selectionEnabled && CheckboxComponent} + {selectionEnabled && ( + + handleCheckboxPress(event as unknown as MouseEvent)} + /> + + )} {renderChildren(state)} ) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index a9b10ca0d860..959e478573e9 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -9,9 +9,9 @@ export type UseSelectionProps = { export type SelectionMethods = { handleSelectAll: () => void; - handleMultipleRowSelection: (rowKey: string) => void; + handleMultipleRowSelection: (keyForList: string) => void; - handleSingleRowSelection: (rowKey: string) => void; + handleSingleRowSelection: (keyForList: string) => void; }; export type UseSelectionResult = MiddlewareHookResult>; @@ -29,11 +29,11 @@ export default function useSelection({data}: UseSele /** * */ - const handleMultipleRowSelection = (rowKey: string) => { - const rowKeys = data.map((item) => item.rowKey); - const rowKeyExists = rowKeys.includes(rowKey); + const handleMultipleRowSelection = (keyForList: string) => { + const keyForLists = data.map((item) => item.keyForList); + const keyForListExists = keyForLists.includes(keyForList); - if (!rowKeyExists) { + if (!keyForListExists) { return; } @@ -41,15 +41,15 @@ export default function useSelection({data}: UseSele const lastSelectedRowIsSelected = lastSelectedRowIsSelectedRef.current; if (!lastSelectedRowKey) { - handleSingleRowSelection(rowKey); + handleSingleRowSelection(keyForList); return; } - const currentSelectedRowIndex = rowKeys.indexOf(rowKey); - const lastSelectedRowIndex = rowKeys.indexOf(lastSelectedRowKey); + const currentSelectedRowIndex = keyForLists.indexOf(keyForList); + const lastSelectedRowIndex = keyForLists.indexOf(lastSelectedRowKey); if (currentSelectedRowIndex === -1 || lastSelectedRowIndex === -1) { - handleSingleRowSelection(rowKey); + handleSingleRowSelection(keyForList); return; } @@ -60,7 +60,7 @@ export default function useSelection({data}: UseSele const newSelectedKeys = [...prevSelectedKeys]; for (let i = startIndex; i <= endIndex; i++) { - const key = rowKeys[i]; + const key = keyForLists[i]; if (lastSelectedRowIsSelected) { if (!newSelectedKeys.includes(key)) { newSelectedKeys.push(key); @@ -80,18 +80,18 @@ export default function useSelection({data}: UseSele /** * */ - const handleSingleRowSelection = (rowKey: string) => { + const handleSingleRowSelection = (keyForList: string) => { setSelectedKeys((prevSelectedKeys) => { - if (prevSelectedKeys.includes(rowKey)) { - return prevSelectedKeys.filter((key) => key !== rowKey); + if (prevSelectedKeys.includes(keyForList)) { + return prevSelectedKeys.filter((key) => key !== keyForList); } else { - return [...prevSelectedKeys, rowKey]; + return [...prevSelectedKeys, keyForList]; } }); }; const middleware = (data: DataType[]) => { - return data.map((item) => ({...item, selected: selectedKeys.includes(item.rowKey)})); + return data.map((item) => ({...item, selected: selectedKeys.includes(item.keyForList)})); }; return { diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 6b94c6754d9d..636cd59139f5 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -7,7 +7,7 @@ import {SelectionMethods} from './middlewares/selection'; import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middlewares/sorting'; type TableData = { - rowKey: string; + keyForList: string; }; /** @@ -110,7 +110,7 @@ type TableProps[] | undefined; + data: DataType[] | undefined; /** Whether multi selection is enabled */ selectionEnabled?: boolean; diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 8b3bd9798825..eaa8f92b1b2a 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,6 +1,6 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; -import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn} from '@components/Table/'; +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'; @@ -8,8 +8,7 @@ import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; export type WorkspaceCategoryTableColumnKey = 'name' | 'glCode' | 'approver' | 'enabled' | 'actions'; -export type WorkspaceCategoryTableRowData = { - keyForList: string; +export type WorkspaceCategoryTableRowData = TableData & { name: string; glCode?: string; approverAvatar?: AvatarSource; 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 eb627ef5aaac..2bec634b49b9 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -222,7 +222,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { 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) { @@ -235,7 +235,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; acc.push({ - keyForList: value.name, + keyForList: `${value.name}-${index}`, name: getDecodedCategoryName(value.name), glCode: value['GL Code'], approverAvatar, From 801e4c718a2952117372079a8fab34b279e525f7 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 19 May 2026 15:21:17 -0400 Subject: [PATCH 19/73] add toggling --- src/components/Table/TableHeader.tsx | 8 ++++--- src/components/Table/middlewares/selection.ts | 22 ++++++++++++++----- .../WorkspaceCategoriesTableRow.tsx | 11 +++++++++- .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 3 ++- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index d8eeafe01632..9cd8878249c7 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -6,6 +6,7 @@ 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 useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; @@ -50,7 +51,8 @@ type TableHeaderProps = ViewProps; function TableHeader({style, ...props}: TableHeaderProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, processedData, selectionEnabled} = useTableContext(); + const {translate} = useLocalize(); + const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, processedData, selectionEnabled} = useTableContext(); if (shouldUseNarrowTableLayout && !title) { return null; @@ -102,9 +104,9 @@ function TableHeader {}} + onPress={tableMethods.handleSelectAll} + accessibilityLabel={translate('workspace.common.selectAll')} /> )} diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 959e478573e9..78746efac405 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -21,16 +21,25 @@ export default function useSelection({data}: UseSele const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); + const keyForLists = data.map((item) => item.keyForList); + /** - * + * When the select all checkbox is toggled, select or deselect all of the + * rows in the table */ - const handleSelectAll = () => {}; + const handleSelectAll = () => { + if (selectedKeys.length === data.length) { + setSelectedKeys([]); + } else { + setSelectedKeys(keyForLists); + } + }; /** - * + * 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 keyForLists = data.map((item) => item.keyForList); const keyForListExists = keyForLists.includes(keyForList); if (!keyForListExists) { @@ -78,9 +87,12 @@ export default function useSelection({data}: UseSele }; /** - * + * When a single row is selected in the table, update the selection state */ const handleSingleRowSelection = (keyForList: string) => { + lastSelectedRowKeyRef.current = keyForList; + lastSelectedRowIsSelectedRef.current = !selectedKeys.includes(keyForList); + setSelectedKeys((prevSelectedKeys) => { if (prevSelectedKeys.includes(keyForList)) { return prevSelectedKeys.filter((key) => key !== keyForList); diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index e6979f5d4117..7e31cd1c7e3d 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -2,10 +2,12 @@ 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 Text from '@components/Text'; 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'; @@ -23,6 +25,7 @@ type WorkspaceCategoriesTableRowProps = { export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); return ( @@ -59,7 +62,13 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApprove )} - + + {}} + /> + { From 85276786b90464e13eacc61c58baad9dfdeaee3d Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 20 May 2026 12:53:46 -0400 Subject: [PATCH 20/73] add some mobile slection logic --- src/components/Table/Table.tsx | 4 ++-- src/components/Table/TableRow.tsx | 22 +++++++++++++------ src/components/Table/middlewares/selection.ts | 9 +++++++- .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 3 --- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 5bc9081fa990..8259e3bcb57d 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -157,7 +157,7 @@ function Table({compareItems, initialSortColumn}); - const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data}); + const {middleware: selectionMiddleware, methods: selectionMethods, mobileSelectionEnabled} = useSelection({data}); // Apply the middleware const filteredData = filterMiddleware(data); @@ -214,7 +214,6 @@ function Table}>{children}; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index d747627e332a..4818a89f42dc 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -139,6 +139,14 @@ export default function TableRow({ tableMethods.handleSingleRowSelection(item.keyForList); }; + const handleRowLongPress = () => { + if (!isInteractive) { + return; + } + + tableMethods.setMobileSelectionEnabled(true); + }; + return ( {(state) => ( @@ -169,13 +178,12 @@ export default function TableRow({ ) : ( {selectionEnabled && ( - - handleCheckboxPress(event as unknown as MouseEvent)} - /> - + handleCheckboxPress(event as unknown as MouseEvent)} + style={styles.flex1} + /> )} {renderChildren(state)} diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 78746efac405..2b445a5b3c8f 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -12,14 +12,19 @@ export type SelectionMethods = { handleMultipleRowSelection: (keyForList: string) => void; handleSingleRowSelection: (keyForList: string) => void; + + setMobileSelectionEnabled: (enabled: boolean) => void; }; -export type UseSelectionResult = MiddlewareHookResult>; +export type UseSelectionResult = MiddlewareHookResult> & { + mobileSelectionEnabled: boolean; +}; export default function useSelection({data}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); + const [mobileSelectionEnabled, setMobileSelectionEnabled] = useState(false); const keyForLists = data.map((item) => item.keyForList); @@ -108,10 +113,12 @@ export default function useSelection({data}: UseSele return { middleware, + mobileSelectionEnabled, methods: { handleSelectAll, handleMultipleRowSelection, handleSingleRowSelection, + setMobileSelectionEnabled, }, }; } diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 0f4c0646eba5..1dec0d5f7ba7 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -97,6 +97,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover Date: Wed, 20 May 2026 14:03:08 -0400 Subject: [PATCH 21/73] use global onyx state for selection enabled --- src/components/Table/Table.tsx | 4 +- src/components/Table/TableRow.tsx | 14 ++++-- src/components/Table/middlewares/selection.ts | 9 +--- .../WorkspaceCategoriesTableRow.tsx | 46 ++++++++++--------- .../Tables/WorkspaceCategoriesTable/index.tsx | 3 ++ 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 8259e3bcb57d..ae010317717a 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -157,7 +157,7 @@ function Table({compareItems, initialSortColumn}); - const {middleware: selectionMiddleware, methods: selectionMethods, mobileSelectionEnabled} = useSelection({data}); + const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data}); // Apply the middleware const filteredData = filterMiddleware(data); @@ -226,7 +226,7 @@ function Table}>{children}; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 4818a89f42dc..db945e37f0f5 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -9,8 +9,11 @@ import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWi import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import useSkeletonSpan from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -61,6 +64,8 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isMobileSelectionEnabled = useMobileSelectionMode(); const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled} = useTableContext(); const item = processedData[rowIndex]; @@ -69,8 +74,9 @@ export default function TableRow({ const isLastRow = rowIndex === rowCount - 1; const isInteractive = interactive && !isLoading; const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); + const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); - if (selectionEnabled) { + if (selectionEnabled && isSelectionCheckboxVisible) { gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); } @@ -130,6 +136,8 @@ export default function TableRow({ return children; }; + const handleRowPress = () => {}; + const handleCheckboxPress = (event?: MouseEvent) => { if (event && event.shiftKey) { tableMethods.handleMultipleRowSelection(item.keyForList); @@ -144,7 +152,7 @@ export default function TableRow({ return; } - tableMethods.setMobileSelectionEnabled(true); + turnOnMobileSelectionMode(); }; return ( @@ -177,7 +185,7 @@ export default function TableRow({ ) : ( - {selectionEnabled && ( + {isSelectionCheckboxVisible && ( void; handleSingleRowSelection: (keyForList: string) => void; - - setMobileSelectionEnabled: (enabled: boolean) => void; }; -export type UseSelectionResult = MiddlewareHookResult> & { - mobileSelectionEnabled: boolean; -}; +export type UseSelectionResult = MiddlewareHookResult>; export default function useSelection({data}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); - const [mobileSelectionEnabled, setMobileSelectionEnabled] = useState(false); const keyForLists = data.map((item) => item.keyForList); @@ -113,12 +108,10 @@ export default function useSelection({data}: UseSele return { middleware, - mobileSelectionEnabled, methods: { handleSelectAll, handleMultipleRowSelection, handleSingleRowSelection, - setMobileSelectionEnabled, }, }; } diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 7e31cd1c7e3d..c4d4fef36feb 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -15,14 +15,20 @@ import CONST from '@src/CONST'; import {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 approver column is visible on web screens or not */ shouldShowApproverColumn: boolean; }; -export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { +export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTableLayout, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -42,11 +48,13 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApprove {item.name} - - {item.glCode} - + {!shouldUseNarrowTableLayout && ( + + {item.glCode} + + )} - {shouldShowApproverColumn && ( + {!shouldUseNarrowTableLayout && shouldShowApproverColumn && ( {item.approverDisplayName && item.approverAccountID && ( <> @@ -62,23 +70,19 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldShowApprove )} - - {}} - /> - + {}} + /> - - - + )} diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 1dec0d5f7ba7..891dd129c5f3 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -2,6 +2,7 @@ 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 useResponsiveLayout from '@hooks/useResponsiveLayout'; import {AvatarSource} from '@libs/UserAvatarUtils'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; @@ -29,6 +30,7 @@ type WorkspaceCategoriesTableProps = { export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const categoryTableColumns: Array> = [ { @@ -89,6 +91,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover ); From 0cc1be5671eff6f3cb82ffacb3d9525c4a236b52 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 20 May 2026 14:34:43 -0400 Subject: [PATCH 22/73] add toggling to categories --- src/components/Table/TableRow.tsx | 15 +++++++++++++-- .../WorkspaceCategoriesTableRow.tsx | 2 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 8 ++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index db945e37f0f5..cdf2f493edd5 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -136,7 +136,18 @@ export default function TableRow({ return children; }; - const handleRowPress = () => {}; + const handleRowPress = (event?: MouseEvent) => { + if (!isInteractive) { + return; + } + + if (shouldUseNarrowLayout && selectionEnabled && isMobileSelectionEnabled) { + handleCheckboxPress(event); + return; + } + + onPress?.(); + }; const handleCheckboxPress = (event?: MouseEvent) => { if (event && event.shiftKey) { @@ -166,8 +177,8 @@ export default function TableRow({ hoverStyle={tableRowPressableHoverStyle} pressDimmingValue={isInteractive ? undefined : 1} role={isInteractive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} - onPress={onPress} onLongPress={handleRowLongPress} + onPress={(event) => handleRowPress(event as unknown as MouseEvent)} {...props} > {(state) => ( diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index c4d4fef36feb..154a101bcca0 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -73,7 +73,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa {}} + onToggle={item.onToggleEnabled} /> void; + onToggleEnabled: (enabled: boolean) => void; }; type WorkspaceCategoriesTableProps = { diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 86901573d056..dc106801d60d 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -249,6 +249,14 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Navigation.navigate(path); }, + onToggleEnabled: (enabled: boolean) => { + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } + + updateWorkspaceCategoryEnabled(enabled, value.name); + }, }); return acc; From 541f733499976b38c8ff39fb7e1cb7ca3c6387e2 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 20 May 2026 14:44:44 -0400 Subject: [PATCH 23/73] cleanup code --- src/components/Table/TableContext.tsx | 4 +-- src/components/Table/middlewares/filtering.ts | 2 +- src/components/Table/middlewares/selection.ts | 8 +++-- src/components/Table/types.ts | 5 +++- .../Tables/DomainListTable/index.tsx | 15 ++++++++-- .../Tables/WorkspaceListTable/index.tsx | 29 +++++++++++++++---- src/pages/domain/DomainsListPage.tsx | 1 + src/pages/workspace/WorkspacesListPage.tsx | 2 ++ 8 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 505d6a991e0c..0d4703ceac17 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -2,7 +2,7 @@ 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, TableData, TableMethods, TableRowData} from './types'; +import type {SharedListProps, TableColumn, TableData, TableMethods, TableRow} from './types'; /** * The shape of the Table context value. @@ -25,7 +25,7 @@ type TableContextValue[]; + processedData: TableRow[]; /** The original length of the data array before any processing. */ originalDataLength: number; diff --git a/src/components/Table/middlewares/filtering.ts b/src/components/Table/middlewares/filtering.ts index 9f3f7bd7fb43..832b1d6162b6 100644 --- a/src/components/Table/middlewares/filtering.ts +++ b/src/components/Table/middlewares/filtering.ts @@ -1,5 +1,5 @@ import {useState} from 'react'; -import {TableData} from '../types'; +import type {TableData} from '../types'; import type {Middleware, MiddlewareHookResult} from './types'; /** diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 78746efac405..74f63a5b6095 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -3,26 +3,30 @@ import {TableData, TableRow} from '../types'; import {MiddlewareHookResult} from './types'; export type UseSelectionProps = { + /** The data being used in the table */ data: DataType[]; }; export 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; }; export type UseSelectionResult = MiddlewareHookResult>; export default function useSelection({data}: UseSelectionProps): UseSelectionResult { + const keyForLists = data.map((item) => item.keyForList); + const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); - const keyForLists = data.map((item) => item.keyForList); - /** * When the select all checkbox is toggled, select or deselect all of the * rows in the table diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index fb42e11285ea..6fbca4f5c9ea 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -6,7 +6,11 @@ import type {IsItemInSearchCallback, SearchingMethods} from './middlewares/searc 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; }; @@ -162,7 +166,6 @@ export type { TableRow, TableColumn, TableMethods, - TableRow as TableRowData, TableHandle, TableProps, SharedListProps, diff --git a/src/components/Tables/DomainListTable/index.tsx b/src/components/Tables/DomainListTable/index.tsx index 0f8418ed9b7c..930ce8352977 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; rowType: 'domain'; domainAccountID: number; title: string; @@ -38,8 +39,18 @@ export default function DomainListTable({domains}: DomainListTableProps) { const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const domainTableColumns: Array> = [ - {key: 'domains', label: translate('common.domains')}, - {key: 'actions', width: variables.domainTableActionColumnWidth, label: '', styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}}, + { + 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/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index c662262b7bb7..5807fb343c2f 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, TableRow} from '@components/Table'; import Table from '@components/Table'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -17,6 +17,7 @@ import WorkspaceRow from './WorkspaceTableRow'; type WorkspaceTableColumnKey = 'workspaces' | 'owner' | 'type' | 'actions'; type WorkspaceRowData = { + keyForList: string; rowType: 'workspace'; title: string; icon: AvatarSource; @@ -55,10 +56,28 @@ export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTable const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth; const workspaceTableColumns: Array> = [ - {key: 'workspaces', label: translate('common.workspaces')}, - {key: 'owner', label: translate('common.owner')}, - {key: 'type', label: translate('workspace.common.workspaceType')}, - {key: 'actions', width: variables.workspaceTableActionColumnWidth, label: '', styling: {containerStyles: [styles.justifyContentEnd, styles.pr3]}}, + { + 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/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index 94f83d4f5116..f968676bcc46 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -61,6 +61,7 @@ function DomainsListPage() { title: Str.extractEmailDomain(domain.email), errors: domainErrors?.errors, pendingAction: domain.pendingAction, + keyForList: String(domain.accountID), brickRoadIndicator: hasDomainErrors(domainErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, action: () => navigateToDomain({domainAccountID: domain.accountID, isAdmin: isDomainAdmin}), }); diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 4549af4eee9e..3c765ae5dfd3 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -485,6 +485,7 @@ function WorkspacesListPage() { const pendingWorkspaceRow: WorkspaceRowData = { rowType: 'workspace', + keyForList: policyID, policyID, disabled: true, errors: undefined, @@ -514,6 +515,7 @@ function WorkspacesListPage() { const ownerDetails = policyOwnerAccountID && getPersonalDetailsByIDs({accountIDs: [policyOwnerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); const workspaceRow: WorkspaceRowData = { + keyForList: policy.id, rowType: 'workspace', policyID: policy.id, disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, From 0b3646695ce3372aa1ac6ce6d3a53984e22b1d2e Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 20 May 2026 14:49:55 -0400 Subject: [PATCH 24/73] stash --- src/components/Tables/WorkspaceListTable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 5807fb343c2f..1fce80f6da51 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, TableData, TableHandle, TableRow} from '@components/Table'; +import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableHandle} from '@components/Table'; import Table from '@components/Table'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; From fc0a2bd34e438a0ae3ed625cc0f393bf8f06c50b Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 09:01:14 -0400 Subject: [PATCH 25/73] add checkbox to header --- src/components/Table/TableHeader.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 67efc2a686b9..20e7379ec34b 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -7,6 +7,8 @@ import {PressableWithFeedback} from '@components/Pressable'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; @@ -52,7 +54,10 @@ function TableHeader(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isMobileSelectionEnabled = useMobileSelectionMode(); + const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData} = useTableContext(); + const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); if (shouldUseNarrowTableLayout && !title) { return null; @@ -64,7 +69,7 @@ function TableHeader (column.width ? `${column.width}px` : '1fr')); - if (selectionEnabled) { + if (isSelectionCheckboxVisible) { gridTemplateColumns.unshift(`${variables.tableCheckboxColumnWidth}px`); } @@ -90,7 +95,16 @@ function TableHeader {shouldUseNarrowTableLayout && ( - + + {isSelectionCheckboxVisible && ( + + )} + Date: Thu, 21 May 2026 09:19:18 -0400 Subject: [PATCH 26/73] add indeterminate checks --- src/components/Table/TableHeader.tsx | 16 ++++++++++++++-- src/components/Table/middlewares/selection.ts | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 20e7379ec34b..e2cf0981f2cf 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -73,6 +73,16 @@ function TableHeader {isSelectionCheckboxVisible && ( diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 74f63a5b6095..7979344072c4 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -27,12 +27,24 @@ export default function useSelection({data}: UseSele const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); + let areAllRowsSelected = true; + let modifiedData: TableRow[] = []; + + for (const item of data) { + const isSelected = selectedKeys.includes(item.keyForList); + modifiedData.push({...item, selected: isSelected}); + + if (!isSelected) { + areAllRowsSelected = false; + } + } + /** * When the select all checkbox is toggled, select or deselect all of the * rows in the table */ const handleSelectAll = () => { - if (selectedKeys.length === data.length) { + if (areAllRowsSelected) { setSelectedKeys([]); } else { setSelectedKeys(keyForLists); @@ -106,8 +118,8 @@ export default function useSelection({data}: UseSele }); }; - const middleware = (data: DataType[]) => { - return data.map((item) => ({...item, selected: selectedKeys.includes(item.keyForList)})); + const middleware = () => { + return modifiedData; }; return { From fc6038c31e476481fbd22265491589c194104c89 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 09:51:31 -0400 Subject: [PATCH 27/73] add row seleection logic --- src/components/Table/Table.tsx | 3 +- src/components/Table/middlewares/selection.ts | 32 +++++++++++++++---- src/components/Table/types.ts | 3 ++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index ae010317717a..be53479e8573 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -143,6 +143,7 @@ function Table) { if (!columns || columns.length === 0) { @@ -157,7 +158,7 @@ function Table({compareItems, initialSortColumn}); - const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data}); + const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data, onRowSelectionChange}); // Apply the middleware const filteredData = filterMiddleware(data); diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 7979344072c4..fce71e36c035 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,10 +1,13 @@ -import {useRef, useState} from 'react'; +import {Dispatch, SetStateAction, useRef, useState} from 'react'; import {TableData, TableRow} from '../types'; import {MiddlewareHookResult} from './types'; export type UseSelectionProps = { /** The data being used in the table */ data: DataType[]; + + /** Callback that is fired when the selection of rows in the table changes */ + onRowSelectionChange?: (selectedRows: TableRow[]) => void; }; export type SelectionMethods = { @@ -20,7 +23,7 @@ export type SelectionMethods = { export type UseSelectionResult = MiddlewareHookResult>; -export default function useSelection({data}: UseSelectionProps): UseSelectionResult { +export default function useSelection({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const keyForLists = data.map((item) => item.keyForList); const lastSelectedRowKeyRef = useRef(null); @@ -39,15 +42,32 @@ export default function useSelection({data}: UseSele } } + /** + * Helper method to ensure that the row selection callback is called every time that the selected + * keys are updated + */ + const updateSelectedKeys = (action: (previousSelectedKeys: string[]) => string[]) => { + setSelectedKeys((previousSelectedKeys) => { + const updatedValue = action(previousSelectedKeys); + + if (onRowSelectionChange) { + const visibleSelectedRows = modifiedData.filter((row) => updatedValue.includes(row.keyForList)); + onRowSelectionChange(visibleSelectedRows); + } + + return updatedValue; + }); + }; + /** * When the select all checkbox is toggled, select or deselect all of the * rows in the table */ const handleSelectAll = () => { if (areAllRowsSelected) { - setSelectedKeys([]); + updateSelectedKeys(() => []); } else { - setSelectedKeys(keyForLists); + updateSelectedKeys(() => keyForLists); } }; @@ -81,7 +101,7 @@ export default function useSelection({data}: UseSele const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); const endIndex = Math.max(currentSelectedRowIndex, lastSelectedRowIndex); - setSelectedKeys((prevSelectedKeys) => { + updateSelectedKeys((prevSelectedKeys) => { const newSelectedKeys = [...prevSelectedKeys]; for (let i = startIndex; i <= endIndex; i++) { @@ -109,7 +129,7 @@ export default function useSelection({data}: UseSele lastSelectedRowKeyRef.current = keyForList; lastSelectedRowIsSelectedRef.current = !selectedKeys.includes(keyForList); - setSelectedKeys((prevSelectedKeys) => { + updateSelectedKeys((prevSelectedKeys) => { if (prevSelectedKeys.includes(keyForList)) { return prevSelectedKeys.filter((key) => key !== keyForList); } else { diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 6fbca4f5c9ea..03e73735782f 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -159,6 +159,9 @@ type TableProps>; + + /** Callback when an option is selected */ + onRowSelectionChange?: (selectedRows: TableRow[]) => void; }>; export type { From 1729f47d1aa2f86375478fd497d733b721a26c08 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 09:58:35 -0400 Subject: [PATCH 28/73] add row selection handling --- .../Tables/WorkspaceCategoriesTable/index.tsx | 5 +- .../categories/WorkspaceCategoriesPage.tsx | 116 ++++-------------- 2 files changed, 31 insertions(+), 90 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index b873f2441824..56d1590736d4 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -27,9 +27,11 @@ type WorkspaceCategoriesTableProps = { categories: WorkspaceCategoryTableRowData[]; shouldShowApproverColumn: boolean; + + onRowSelectionChange: (selectedRows: WorkspaceCategoryTableRowData[]) => void; }; -export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn}: WorkspaceCategoriesTableProps) { +export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -106,6 +108,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover compareItems={compareItems} isItemInSearch={isItemInSearch} renderItem={renderCategoryItem} + onRowSelectionChange={onRowSelectionChange} > diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index dc106801d60d..ad62288a0fd4 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -91,7 +91,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedCategoryNames, setSelectedCategoryNames] = useState([]); const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeEnabled : true; const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled; @@ -130,15 +130,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const cleanupSelectedOption = useCallback(() => setSelectedCategories([]), []); + const cleanupSelectedOption = useCallback(() => setSelectedCategoryNames([]), []); useCleanupSelectedOptions(cleanupSelectedOption); useEffect(() => { - if (selectedCategories.length === 0 || !canSelectMultiple) { + if (selectedCategoryNames.length === 0 || !canSelectMultiple) { return; } - setSelectedCategories((prevSelectedCategories) => { + setSelectedCategoryNames((prevSelectedCategories) => { const newSelectedCategories = []; for (const categoryName of prevSelectedCategories) { @@ -162,7 +162,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [policyCategories]); useSearchBackPress({ - onClearSelection: () => setSelectedCategories([]), + onClearSelection: () => setSelectedCategoryNames([]), onNavigationCallBack: () => Navigation.goBack(backTo), }); @@ -365,71 +365,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); - 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 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)); - }; - - const getCustomListHeader = () => { - if (filteredCategoryList.length === 0) { - return null; - } - - // Show GL Code column only on wide screens for control policies. Approver column additionally requires rules to be enabled - if (isControlPolicyWithWideLayout) { - return ( - - - {translate('common.name')} - - - {translate('workspace.categories.glCode')} - - {shouldShowApproverColumn && ( - - {translate('common.approver')} - - )} - - {translate('common.enabled')} - - - ); - } - - return ( - - ); - }; - - const navigateToCategorySettings = (category: ListItem) => { - if (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)), - ); + const handleCategorySelectionChange = (categories: WorkspaceCategoryTableRowData[]) => { + setSelectedCategoryNames(categories.map((category) => category.name)); }; const navigateToCategoriesSettings = useCallback(() => { @@ -445,10 +382,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }; const handleDeleteCategories = () => { - if (selectedCategories.length > 0) { + if (selectedCategoryNames.length > 0) { deleteWorkspaceCategories( policyData, - selectedCategories, + selectedCategoryNames, isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, @@ -458,7 +395,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); } - setSelectedCategories([]); + setSelectedCategoryNames([]); }; const hasVisibleCategories = categoryRows.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); @@ -546,13 +483,13 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const getHeaderButtons = () => { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; - const selectedCategoriesObject = selectedCategories.map((key) => policyCategories?.[key]); + const selectedCategoriesObject = selectedCategoryNames.map((key) => policyCategories?.[key]); - if (isSmallScreenWidth ? canSelectMultiple : selectedCategories.length > 0) { + if (isSmallScreenWidth ? canSelectMultiple : selectedCategoryNames.length > 0) { if (!isThereAnyAccountingConnection) { options.push({ icon: icons.Trashcan, - text: translate(selectedCategories.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), + text: translate(selectedCategoryNames.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, onSelected: async () => { if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, selectedCategoriesObject)) { @@ -561,8 +498,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(selectedCategoryNames.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), + prompt: translate(selectedCategoryNames.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt'), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, @@ -574,9 +511,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const enabledCategories = selectedCategories.filter((categoryName) => policyCategories?.[categoryName]?.enabled); + const enabledCategories = selectedCategoryNames.filter((categoryName) => policyCategories?.[categoryName]?.enabled); if (enabledCategories.length > 0) { - const categoriesToDisable = selectedCategories + const categoriesToDisable = selectedCategoryNames .filter((categoryName) => policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -594,7 +531,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { showCannotDeleteOrDisableLastCategoryModal(); return; } - setSelectedCategories([]); + setSelectedCategoryNames([]); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToDisable, @@ -615,9 +552,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const disabledCategories = selectedCategories.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); + const disabledCategories = selectedCategoryNames.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); if (disabledCategories.length > 0) { - const categoriesToEnable = selectedCategories + const categoriesToEnable = selectedCategoryNames .filter((categoryName) => !policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -631,7 +568,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([]); + setSelectedCategoryNames([]); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToEnable, @@ -657,11 +594,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: selectedCategoryNames.length})} options={options} isSplitButton={false} style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} - isDisabled={!selectedCategories.length} + isDisabled={!selectedCategoryNames.length} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CATEGORIES.BULK_ACTIONS_DROPDOWN} testID="WorkspaceCategoriesPage-header-dropdown-menu-button" /> @@ -702,8 +639,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return; } - setSelectedCategories([]); - }, [setSelectedCategories, isMobileSelectionModeEnabled]); + setSelectedCategoryNames([]); + }, [setSelectedCategoryNames, isMobileSelectionModeEnabled]); const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; @@ -763,7 +700,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldDisplayHelpButton onBackButtonPress={() => { if (isMobileSelectionModeEnabled) { - setSelectedCategories([]); + setSelectedCategoryNames([]); turnOffMobileSelectionMode(); return; } @@ -791,6 +728,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // Date: Thu, 21 May 2026 10:20:26 -0400 Subject: [PATCH 29/73] remove dead code --- .../categories/WorkspaceCategoriesPage.tsx | 98 +------------------ 1 file changed, 2 insertions(+), 96 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index ad62288a0fd4..2e7f3574c4ff 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'; @@ -15,7 +14,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import SearchBar from '@components/SearchBar'; import type {ListItem} from '@components/SelectionList/types'; -import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; import WorkspaceCategoriesTable, {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; @@ -260,79 +258,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); 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, @@ -355,12 +280,14 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { 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 ?? '')); }, [localeCompare], ); + const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryRows, filterCategory, sortCategories); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); @@ -730,27 +657,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldShowApproverColumn={shouldShowApproverColumn} onRowSelectionChange={handleCategorySelectionChange} /> - - // 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 && ( From c8b41929a5fc8fcc536f076f7b3d82866460f27d Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 11:15:39 -0400 Subject: [PATCH 30/73] add selection to the table ref --- src/components/Table/middlewares/selection.ts | 11 ++++++++++ .../Tables/WorkspaceCategoriesTable/index.tsx | 7 ++++-- .../categories/WorkspaceCategoriesPage.tsx | 22 +++++++++++-------- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index fce71e36c035..f3ee75cf8699 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -19,6 +19,9 @@ export type SelectionMethods = { /** 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; }; export type UseSelectionResult = MiddlewareHookResult>; @@ -59,6 +62,13 @@ export default function useSelection({data, onRowSel }); }; + /** + * Clear all of the currently selected keys + */ + const clearSelection = () => { + updateSelectedKeys(() => []); + }; + /** * When the select all checkbox is toggled, select or deselect all of the * rows in the table @@ -148,6 +158,7 @@ export default function useSelection({data, onRowSel handleSelectAll, handleMultipleRowSelection, handleSingleRowSelection, + clearSelection, }, }; } diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 56d1590736d4..89c45285b55c 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,6 +1,6 @@ import type {ListRenderItemInfo} from '@shopify/flash-list'; import React from 'react'; -import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table/'; +import Table, {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table/'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {AvatarSource} from '@libs/UserAvatarUtils'; @@ -24,6 +24,8 @@ export type WorkspaceCategoryTableRowData = TableData & { }; type WorkspaceCategoriesTableProps = { + ref?: React.Ref> | undefined; + categories: WorkspaceCategoryTableRowData[]; shouldShowApproverColumn: boolean; @@ -31,7 +33,7 @@ type WorkspaceCategoriesTableProps = { onRowSelectionChange: (selectedRows: WorkspaceCategoryTableRowData[]) => void; }; -export default function WorkspaceCategoriesTable({categories, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { +export default function WorkspaceCategoriesTable({ref, categories, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -101,6 +103,7 @@ export default function WorkspaceCategoriesTable({categories, shouldShowApprover return (
; function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { + const tableRef = useRef>(null); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); @@ -128,7 +130,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const cleanupSelectedOption = useCallback(() => setSelectedCategoryNames([]), []); + const cleanupSelectedOption = useCallback(() => tableRef.current?.clearSelection(), []); useCleanupSelectedOptions(cleanupSelectedOption); useEffect(() => { @@ -160,7 +162,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [policyCategories]); useSearchBackPress({ - onClearSelection: () => setSelectedCategoryNames([]), + onClearSelection: () => tableRef.current?.clearSelection(), onNavigationCallBack: () => Navigation.goBack(backTo), }); @@ -322,8 +324,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); } - setSelectedCategoryNames([]); + tableRef.current?.clearSelection(); }; + const hasVisibleCategories = categoryRows.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const policyHasAccountingConnections = hasAccountingConnections(policy); @@ -458,7 +461,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { showCannotDeleteOrDisableLastCategoryModal(); return; } - setSelectedCategoryNames([]); + tableRef.current?.clearSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToDisable, @@ -495,7 +498,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'), value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, onSelected: () => { - setSelectedCategoryNames([]); + tableRef.current?.clearSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToEnable, @@ -566,7 +569,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return; } - setSelectedCategoryNames([]); + tableRef.current?.clearSelection(); }, [setSelectedCategoryNames, isMobileSelectionModeEnabled]); const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; @@ -627,7 +630,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldDisplayHelpButton onBackButtonPress={() => { if (isMobileSelectionModeEnabled) { - setSelectedCategoryNames([]); + tableRef.current?.clearSelection(); turnOffMobileSelectionMode(); return; } @@ -653,6 +656,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { )} {hasVisibleCategories && !isLoading && ( Date: Thu, 21 May 2026 11:25:41 -0400 Subject: [PATCH 31/73] cleanup code in workspace categories list --- .../WorkspaceCategoriesTableRow.tsx | 7 +++- .../Tables/WorkspaceCategoriesTable/index.tsx | 4 +-- .../categories/WorkspaceCategoriesPage.tsx | 36 +++++++++++-------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 154a101bcca0..05e421481c7f 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -39,8 +39,13 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa interactive rowIndex={rowIndex} skeletonReasonAttributes={{context: 'categoriesTableRow'}} - offlineWithFeedback={{errors: item.errors, pendingAction: item.pendingAction, shouldHideOnDelete: false}} onPress={item.action} + offlineWithFeedback={{ + errors: item.errors, + pendingAction: item.pendingAction, + shouldHideOnDelete: false, + dismissError: item.dismissError, + }} > {({hovered}) => ( <> diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 89c45285b55c..266fdd6f70aa 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -20,16 +20,14 @@ export type WorkspaceCategoryTableRowData = TableData & { errors?: OnyxCommon.Errors; pendingAction?: OnyxCommon.PendingAction; action: () => void; + dismissError: () => void; onToggleEnabled: (enabled: boolean) => void; }; type WorkspaceCategoriesTableProps = { ref?: React.Ref> | undefined; - categories: WorkspaceCategoryTableRowData[]; - shouldShowApproverColumn: boolean; - onRowSelectionChange: (selectedRows: WorkspaceCategoryTableRowData[]) => void; }; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index facf68c7e835..06740adf37d6 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -59,6 +59,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import {PolicyCategories} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; type WorkspaceCategoriesPageProps = @@ -216,6 +217,23 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const glCodeTextStyle = useMemo(() => [styles.alignSelfStart], [styles.alignSelfStart]); const switchContainerStyle = useMemo(() => [StyleUtils.getMinimumWidth(variables.w72)], [StyleUtils]); + const navigateToCategory = (category: PolicyCategories[string]) => { + const path = isQuickSettingsFlow + ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, category.name, backTo) + : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.name)); + + Navigation.navigate(path); + }; + + const handleCategoryToggle = (enabled: boolean, category: PolicyCategories[string]) => { + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } + + updateWorkspaceCategoryEnabled(enabled, category.name); + }; + const categoryRows = useMemo(() => { const categories = Object.values(policyCategories ?? {}); @@ -242,21 +260,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { enabled: value.enabled, errors: value.errors ?? undefined, 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); - }, - onToggleEnabled: (enabled: boolean) => { - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } - - updateWorkspaceCategoryEnabled(enabled, value.name); - }, + action: () => navigateToCategory(value), + onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), + dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), }); return acc; From d0b5c9a667607131f8dd57b4f38775e36428f4f2 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 12:18:03 -0400 Subject: [PATCH 32/73] fix lint & types --- src/components/Table/TableContext.tsx | 4 +- src/components/Table/TableHeader.tsx | 4 +- src/components/Table/TableRow.tsx | 32 +++++---- src/components/Table/middlewares/filtering.ts | 2 +- src/components/Table/middlewares/selection.ts | 57 +++++++++------- src/components/Table/types.ts | 2 +- .../WorkspaceCategoriesTableRow.tsx | 4 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 23 ++++--- .../WorkspaceCompanyCardsTableRow.tsx | 3 +- .../Tables/WorkspaceListTable/index.tsx | 2 +- src/pages/domain/DomainsListPage.tsx | 2 +- .../categories/WorkspaceCategoriesPage.tsx | 68 +++++++------------ 12 files changed, 100 insertions(+), 103 deletions(-) diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 0d4703ceac17..1d0c03fa6d46 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -25,7 +25,7 @@ type TableContextValue[]; + processedData: Array>; /** The original length of the data array before any processing. */ originalDataLength: number; @@ -79,8 +79,6 @@ const defaultTableContextValue: TableContextValue = { hasSearchString: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, - handleShiftRowSelection: () => {}, - handleRowSelection: () => {}, }; const TableContext = createContext(defaultTableContextValue); diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index e2cf0981f2cf..97bfcb4bdda5 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -106,7 +106,7 @@ function TableHeader {shouldUseNarrowTableLayout && ( - {isSelectionCheckboxVisible && ( + {!!isSelectionCheckboxVisible && ( - {selectionEnabled && ( + {!!selectionEnabled && ( { if (!isInteractive) { return undefined; - } else if (item.selected) { + } + if (item.selected) { return styles.activeComponentBG; - } else { - return styles.hoveredComponentBG; } + return styles.hoveredComponentBG; })(); const renderChildren = (state: PressableStateCallbackType) => { @@ -136,6 +140,15 @@ export default function TableRow({ return children; }; + const handleCheckboxPress = (event?: MouseEvent) => { + if (event && event.shiftKey) { + tableMethods.handleMultipleRowSelection(item.keyForList); + return; + } + + tableMethods.handleSingleRowSelection(item.keyForList); + }; + const handleRowPress = (event?: MouseEvent) => { if (!isInteractive) { return; @@ -149,15 +162,6 @@ export default function TableRow({ onPress?.(); }; - const handleCheckboxPress = (event?: MouseEvent) => { - if (event && event.shiftKey) { - tableMethods.handleMultipleRowSelection(item.keyForList); - return; - } - - tableMethods.handleSingleRowSelection(item.keyForList); - }; - const handleRowLongPress = () => { if (!isInteractive) { return; @@ -196,7 +200,7 @@ export default function TableRow({ ) : ( - {isSelectionCheckboxVisible && ( + {!!isSelectionCheckboxVisible && ( = { +type UseSelectionProps = { /** The data being used in the table */ data: DataType[]; /** Callback that is fired when the selection of rows in the table changes */ - onRowSelectionChange?: (selectedRows: TableRow[]) => void; + onRowSelectionChange?: (selectedRows: Array>) => void; }; -export type SelectionMethods = { +type SelectionMethods = { /** Callback to either select or unselect all rows in the table */ handleSelectAll: () => void; @@ -24,7 +24,7 @@ export type SelectionMethods = { clearSelection: () => void; }; -export type UseSelectionResult = MiddlewareHookResult>; +type UseSelectionResult = MiddlewareHookResult>; export default function useSelection({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const keyForLists = data.map((item) => item.keyForList); @@ -34,7 +34,7 @@ export default function useSelection({data, onRowSel const [selectedKeys, setSelectedKeys] = useState([]); let areAllRowsSelected = true; - let modifiedData: TableRow[] = []; + const modifiedData: Array> = []; for (const item of data) { const isSelected = selectedKeys.includes(item.keyForList); @@ -81,6 +81,22 @@ export default function useSelection({data, onRowSel } }; + /** + * When a single row is selected in the table, update the selection state + */ + const handleSingleRowSelection = (keyForList: string) => { + lastSelectedRowKeyRef.current = keyForList; + lastSelectedRowIsSelectedRef.current = !selectedKeys.includes(keyForList); + + updateSelectedKeys((prevSelectedKeys) => { + if (prevSelectedKeys.includes(keyForList)) { + return prevSelectedKeys.filter((key) => key !== keyForList); + } + + return [...prevSelectedKeys, keyForList]; + }); + }; + /** * When a row is selected, while holding shift, select all of the rows in-between * the last selected row and the current row @@ -115,7 +131,12 @@ export default function useSelection({data, onRowSel const newSelectedKeys = [...prevSelectedKeys]; for (let i = startIndex; i <= endIndex; i++) { - const key = keyForLists[i]; + const key = keyForLists.at(i); + + if (!key) { + continue; + } + if (lastSelectedRowIsSelected) { if (!newSelectedKeys.includes(key)) { newSelectedKeys.push(key); @@ -132,22 +153,6 @@ export default function useSelection({data, onRowSel }); }; - /** - * When a single row is selected in the table, update the selection state - */ - const handleSingleRowSelection = (keyForList: string) => { - lastSelectedRowKeyRef.current = keyForList; - lastSelectedRowIsSelectedRef.current = !selectedKeys.includes(keyForList); - - updateSelectedKeys((prevSelectedKeys) => { - if (prevSelectedKeys.includes(keyForList)) { - return prevSelectedKeys.filter((key) => key !== keyForList); - } else { - return [...prevSelectedKeys, keyForList]; - } - }); - }; - const middleware = () => { return modifiedData; }; @@ -162,3 +167,5 @@ export default function useSelection({data, onRowSel }, }; } + +export type {SelectionMethods, UseSelectionProps, UseSelectionResult}; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 03e73735782f..90283e87eab1 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -161,7 +161,7 @@ type TableProps>; /** Callback when an option is selected */ - onRowSelectionChange?: (selectedRows: TableRow[]) => void; + onRowSelectionChange?: (selectedRows: Array>) => void; }>; export type { diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 05e421481c7f..8d7c34bb7aa4 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -12,7 +12,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import {WorkspaceCategoryTableRowData} from '.'; +import type {WorkspaceCategoryTableRowData} from '.'; type WorkspaceCategoriesTableRowProps = { /** Data about the category */ @@ -61,7 +61,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa {!shouldUseNarrowTableLayout && shouldShowApproverColumn && ( - {item.approverDisplayName && item.approverAccountID && ( + {!!item.approverDisplayName && !!item.approverAccountID && ( <> ); } + +export type {WorkspaceCategoryTableRowData, WorkspaceCategoryTableColumnKey}; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx index b9fc8c897415..df4a902111dc 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx @@ -4,7 +4,8 @@ import {View} from 'react-native'; import Button from '@components/Button'; import Icon from '@components/Icon'; import ReportActionAvatars from '@components/ReportActionAvatars'; -import Table, {TableData} from '@components/Table'; +import type {TableData} from '@components/Table'; +import Table from '@components/Table'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 663352011dea..e2d5947746d4 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -7,7 +7,7 @@ import Table from '@components/Table'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; +import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {AvatarSource} from '@libs/UserUtils'; import WorkspacesEmptyStateComponent from '@pages/workspace/WorkspacesEmptyStateComponent'; import variables from '@styles/variables'; diff --git a/src/pages/domain/DomainsListPage.tsx b/src/pages/domain/DomainsListPage.tsx index 334106e1d34d..2d498e467f9f 100644 --- a/src/pages/domain/DomainsListPage.tsx +++ b/src/pages/domain/DomainsListPage.tsx @@ -54,13 +54,13 @@ 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, title: Str.extractEmailDomain(domain.email), errors: domainErrors?.errors, pendingAction: domain.pendingAction, - keyForList: String(domain.accountID), brickRoadIndicator: hasDomainErrors(domainErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, action: () => navigateToDomain({domainAccountID: domain.accountID, isAdmin: isDomainAdmin}), }); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 06740adf37d6..556af7a58744 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -14,8 +14,9 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import SearchBar from '@components/SearchBar'; import type {ListItem} from '@components/SelectionList/types'; -import {TableHandle} from '@components/Table'; -import WorkspaceCategoriesTable, {WorkspaceCategoryTableColumnKey, WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; +import type {TableHandle} from '@components/Table'; +import type {WorkspaceCategoryTableColumnKey, 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'; @@ -34,13 +35,11 @@ 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'; @@ -52,14 +51,13 @@ import {getConnectedIntegration, getCurrentConnectionName, hasAccountingConnecti 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 {PolicyCategories} from '@src/types/onyx'; +import type {PolicyCategories} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; type WorkspaceCategoriesPageProps = @@ -72,7 +70,6 @@ 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 [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); @@ -213,26 +210,28 @@ 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 navigateToCategory = (category: PolicyCategories[string]) => { - const path = isQuickSettingsFlow - ? ROUTES.SETTINGS_CATEGORY_SETTINGS.getRoute(policyId, category.name, backTo) - : createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(category.name)); - - Navigation.navigate(path); - }; + Navigation.navigate(path); + }, + [backTo, isQuickSettingsFlow, policyId], + ); - const handleCategoryToggle = (enabled: boolean, category: PolicyCategories[string]) => { - if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { - showCannotDeleteOrDisableLastCategoryModal(); - return; - } + const handleCategoryToggle = useCallback( + (enabled: boolean, category: PolicyCategories[string]) => { + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { + showCannotDeleteOrDisableLastCategoryModal(); + return; + } - updateWorkspaceCategoryEnabled(enabled, category.name); - }; + updateWorkspaceCategoryEnabled(enabled, category.name); + }, + [policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, updateWorkspaceCategoryEnabled], + ); const categoryRows = useMemo(() => { const categories = Object.values(policyCategories ?? {}); @@ -267,22 +266,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return acc; }, []); - }, [ - showCannotDeleteOrDisableLastCategoryModal, - policyCategories, - isOffline, - translate, - updateWorkspaceCategoryEnabled, - policy, - isControlPolicyWithWideLayout, - glCodeContainerStyle, - glCodeTextStyle, - switchContainerStyle, - shouldShowApproverColumn, - styles.alignItemsCenter, - styles.flexRow, - styles.mr3, - ]); + }, [policyCategories, isOffline, shouldShowApproverColumn, policy?.rules?.approvalRules, navigateToCategory, handleCategoryToggle, policyId]); const filterCategory = useCallback((categoryOption: ListItem, searchInput: string) => { const results = tokenizedSearch([categoryOption], searchInput, (option) => [option.text ?? '', option.alternateText ?? '']); @@ -312,10 +296,6 @@ 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 (selectedCategoryNames.length > 0) { deleteWorkspaceCategories( From 9b0c9edb2364937f220a6b14e14770f0aa5f3c82 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 21 May 2026 14:33:05 -0400 Subject: [PATCH 33/73] fix sorting --- src/components/Table/Table.tsx | 10 ++++------ src/components/Table/TableBody.tsx | 2 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 8 +++----- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index be53479e8573..6909b4b46ff7 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -153,17 +153,15 @@ function Table({filters, isItemInFilter}); + const filteredData = filterMiddleware(data); const {middleware: searchMiddleware, activeSearchString, methods: searchMethods} = useSearching({isItemInSearch}); + const searchedData = searchMiddleware(filteredData); const {middleware: sortMiddleware, activeSorting, methods: sortMethods} = useSorting({compareItems, initialSortColumn}); - - const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data, onRowSelectionChange}); - - // Apply the middleware - const filteredData = filterMiddleware(data); - const searchedData = searchMiddleware(filteredData); const sortedData = sortMiddleware(searchedData); + + const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data: sortedData, onRowSelectionChange}); const processedData = selectionMiddleware(sortedData); const listRef = useRef>(null); diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index 815c766b9896..0ee76bf7e879 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -7,7 +7,7 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {TableData} from '.'; +import type {TableData} from '.'; import {useTableContext} from './TableContext'; /** diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 5aad8e18e054..8481920df8f3 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -80,11 +80,9 @@ export default function WorkspaceCategoriesTable({ref, categories, shouldShowApp } if (activeSorting.columnKey === 'enabled') { - if (item1.enabled === item2.enabled) { - return 0; - } - - return (item1.enabled ? -1 : 1) * orderMultiplier; + const enabled1 = item1.enabled ? 1 : 0; + const enabled2 = item2.enabled ? 1 : 0; + return (enabled1 - enabled2) * orderMultiplier; } return localeCompare(item1.name, item2.name) * orderMultiplier; From 20df429ab002f39d4bbf20c64567b1f620fd6185 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 08:51:59 -0400 Subject: [PATCH 34/73] add searchbar --- src/components/Tables/WorkspaceCategoriesTable/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 8481920df8f3..7b410853313d 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -5,6 +5,7 @@ import Table from '@components/Table/'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import WorkspaceCategoriesTableRow from './WorkspaceCategoriesTableRow'; @@ -114,6 +115,7 @@ export default function WorkspaceCategoriesTable({ref, categories, shouldShowApp renderItem={renderCategoryItem} onRowSelectionChange={onRowSelectionChange} > + {categories.length > CONST.STANDARD_LIST_ITEM_LIMIT && }
From 5a3da72de26946e92adaef2bd1066514b6c3f7c7 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 09:37:21 -0400 Subject: [PATCH 35/73] fix alignment --- .../WorkspaceCategoriesTableRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 8d7c34bb7aa4..5e828988d422 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -49,12 +49,12 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa > {({hovered}) => ( <> - + {item.name} {!shouldUseNarrowTableLayout && ( - + {item.glCode} )} @@ -84,7 +84,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa From 7cc0244d34579f7379142d508b15266f5f3df8b9 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 09:56:42 -0400 Subject: [PATCH 36/73] fix all types --- src/components/Table/Table.tsx | 2 +- tests/ui/TableTest.tsx | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 6909b4b46ff7..e2e2b77ad8cd 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -228,7 +228,7 @@ function Table}>{children}; + return }>{children}; } export default Table; diff --git a/tests/ui/TableTest.tsx b/tests/ui/TableTest.tsx index 14f55baeef38..c4490b0b3b44 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,17 +146,17 @@ 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> = [ - {key: 'name', label: 'Name'}, - {key: 'category', label: 'Category'}, - {key: 'value', label: 'Value'}, + {key: 'name', label: 'Name', sortable: true}, + {key: 'category', label: 'Category', sortable: true}, + {key: 'value', label: 'Value', sortable: true}, ]; // Helper function to create default test props @@ -287,9 +288,9 @@ describe('Table', () => { it('should render column headers with custom styling', () => { const props = createDefaultProps(); const customColumns: Array> = [ - {key: 'name', label: 'Name', styling: {flex: 2}}, - {key: 'category', label: 'Category', styling: {flex: 1}}, - {key: 'value', label: 'Value', styling: {flex: 1}}, + {key: 'name', label: 'Name', styling: {flex: 2}, sortable: true}, + {key: 'category', label: 'Category', styling: {flex: 1}, sortable: true}, + {key: 'value', label: 'Value', styling: {flex: 1}, sortable: true}, ]; render( @@ -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', From 44e2c8d5f554ab8863dec816aef5178ae5e0bbf6 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 10:27:18 -0400 Subject: [PATCH 37/73] fix tests --- tests/ui/WorkspaceCategoriesTest.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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'; From 7d902b92371d4f9ef725239336aef433ad43495c Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 11:01:34 -0400 Subject: [PATCH 38/73] disable line for refs --- .../categories/WorkspaceCategoriesPage.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index a574c3338458..b0c747f08922 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -128,8 +128,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const cleanupSelectedOption = useCallback(() => tableRef.current?.clearSelection(), []); - useCleanupSelectedOptions(cleanupSelectedOption); + const clearTableSelection = useCallback(() => { + tableRef.current?.clearSelection(); + }, []); + + useCleanupSelectedOptions(clearTableSelection); useEffect(() => { if (selectedCategoryNames.length === 0 || !canSelectMultiple) { @@ -160,7 +163,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [policyCategories]); useSearchBackPress({ - onClearSelection: () => tableRef.current?.clearSelection(), + onClearSelection: clearTableSelection, onNavigationCallBack: () => Navigation.goBack(backTo), }); @@ -310,7 +313,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); } - tableRef.current?.clearSelection(); + clearTableSelection(); }; const hasVisibleCategories = categoryRows.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); @@ -396,6 +399,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); + // eslint-disable react-hooks/refs const getHeaderButtons = () => { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; @@ -447,7 +451,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { showCannotDeleteOrDisableLastCategoryModal(); return; } - tableRef.current?.clearSelection(); + clearTableSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToDisable, @@ -484,7 +488,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { text: translate(disabledCategories.length === 1 ? 'workspace.categories.enableCategory' : 'workspace.categories.enableCategories'), value: CONST.POLICY.BULK_ACTION_TYPES.ENABLE, onSelected: () => { - tableRef.current?.clearSelection(); + clearTableSelection(); setWorkspaceCategoryEnabled({ policyData, categoriesToUpdate: categoriesToEnable, @@ -555,8 +559,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return; } - tableRef.current?.clearSelection(); - }, [setSelectedCategoryNames, isMobileSelectionModeEnabled]); + clearTableSelection(); + }, [clearTableSelection, isMobileSelectionModeEnabled]); const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; @@ -616,7 +620,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { shouldDisplayHelpButton onBackButtonPress={() => { if (isMobileSelectionModeEnabled) { - tableRef.current?.clearSelection(); + clearTableSelection(); turnOffMobileSelectionMode(); return; } @@ -629,8 +633,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Navigation.goBack(); }} > + {/* eslint-disable-next-line react-hooks/refs -- Ref is used in a callback when an action is taken */} {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} + {/* eslint-disable-next-line react-hooks/refs -- Ref is used in a callback when an action is taken */} {shouldDisplayButtonsInSeparateLine && {getHeaderButtons()}} {(!hasVisibleCategories || isLoading) && headerContent} {isLoading && ( From c3ffdb8a54a72fdf89fc78db4eb7fa0d2221c2f6 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 22 May 2026 11:14:35 -0400 Subject: [PATCH 39/73] fix PR Comments --- .../Tables/WorkspaceCategoriesTable/index.tsx | 6 ++ .../categories/WorkspaceCategoriesPage.tsx | 92 +++++++------------ 2 files changed, 39 insertions(+), 59 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 7b410853313d..0eb27f8dfc6c 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -86,6 +86,12 @@ export default function WorkspaceCategoriesTable({ref, categories, shouldShowApp 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; }; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index b0c747f08922..54871786a632 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -12,8 +12,6 @@ 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 type {ListItem} from '@components/SelectionList/types'; import type {TableHandle} from '@components/Table'; import type {WorkspaceCategoryTableColumnKey, WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; @@ -33,7 +31,6 @@ import useOnyx from '@hooks/useOnyx'; import usePolicyData from '@hooks/usePolicyData'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; -import useSearchResults from '@hooks/useSearchResults'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -49,7 +46,6 @@ 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 {close} from '@userActions/Modal'; import {clearCategoryErrors, deleteWorkspaceCategories, downloadCategoriesCSV, openPolicyCategoriesPage, setWorkspaceCategoryEnabled} from '@userActions/Policy/Category'; @@ -70,7 +66,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const {showConfirmModal} = useConfirmModal(); const {environmentURL} = useEnvironment(); @@ -89,7 +85,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isQuickSettingsFlow = route.name === SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORIES_ROOT; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [selectedCategoryNames, setSelectedCategoryNames] = useState([]); + const [selectedCategoryKeys, setSelectedCategoryKeys] = useState([]); const canSelectMultiple = isSmallScreenWidth ? isMobileSelectionModeEnabled : true; const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy); const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled; @@ -135,11 +131,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { useCleanupSelectedOptions(clearTableSelection); useEffect(() => { - if (selectedCategoryNames.length === 0 || !canSelectMultiple) { + if (selectedCategoryKeys.length === 0 || !canSelectMultiple) { return; } - setSelectedCategoryNames((prevSelectedCategories) => { + setSelectedCategoryKeys((prevSelectedCategories) => { const newSelectedCategories = []; for (const categoryName of prevSelectedCategories) { @@ -239,7 +235,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const categoryRows = useMemo(() => { const categories = Object.values(policyCategories ?? {}); - return categories.reduce((acc, value, index) => { + return categories.reduce((acc, value) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; if (!isOffline && isDisabled) { @@ -252,7 +248,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; acc.push({ - keyForList: `${value.name}-${index}`, + keyForList: value.name, name: getDecodedCategoryName(value.name), glCode: value['GL Code'], isDisabled, @@ -271,24 +267,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); }, [policyCategories, isOffline, shouldShowApproverColumn, policy?.rules?.approvalRules, navigateToCategory, handleCategoryToggle, policyId]); - 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 ?? '')); - }, - [localeCompare], - ); - - const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryRows, filterCategory, sortCategories); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); const handleCategorySelectionChange = (categories: WorkspaceCategoryTableRowData[]) => { - setSelectedCategoryNames(categories.map((category) => category.name)); + setSelectedCategoryKeys(categories.map((category) => category.keyForList)); }; const navigateToCategoriesSettings = useCallback(() => { @@ -300,10 +282,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }; const handleDeleteCategories = () => { - if (selectedCategoryNames.length > 0) { + if (selectedCategoryKeys.length > 0) { deleteWorkspaceCategories( policyData, - selectedCategoryNames, + selectedCategoryKeys, isSetupCategoryTaskParentReportArchived, setupCategoryTaskReport, setupCategoryTaskParentReport, @@ -403,13 +385,13 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const getHeaderButtons = () => { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; - const selectedCategoriesObject = selectedCategoryNames.map((key) => policyCategories?.[key]); + const selectedCategoriesObject = selectedCategoryKeys.map((key) => policyCategories?.[key]); - if (isSmallScreenWidth ? canSelectMultiple : selectedCategoryNames.length > 0) { + if (isSmallScreenWidth ? canSelectMultiple : selectedCategoryKeys.length > 0) { if (!isThereAnyAccountingConnection) { options.push({ icon: icons.Trashcan, - text: translate(selectedCategoryNames.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)) { @@ -418,8 +400,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { } const {action} = await showConfirmModal({ - title: translate(selectedCategoryNames.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories'), - prompt: translate(selectedCategoryNames.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, @@ -431,9 +413,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const enabledCategories = selectedCategoryNames.filter((categoryName) => policyCategories?.[categoryName]?.enabled); + const enabledCategories = selectedCategoryKeys.filter((categoryName) => policyCategories?.[categoryName]?.enabled); if (enabledCategories.length > 0) { - const categoriesToDisable = selectedCategoryNames + const categoriesToDisable = selectedCategoryKeys .filter((categoryName) => policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -472,9 +454,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }); } - const disabledCategories = selectedCategoryNames.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); + const disabledCategories = selectedCategoryKeys.filter((categoryName) => !policyCategories?.[categoryName]?.enabled); if (disabledCategories.length > 0) { - const categoriesToEnable = selectedCategoryNames + const categoriesToEnable = selectedCategoryKeys .filter((categoryName) => !policyCategories?.[categoryName]?.enabled) .reduce>((acc, categoryName) => { acc[categoryName] = { @@ -514,11 +496,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { onPress={() => null} shouldAlwaysShowDropdownMenu buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedCategoryNames.length})} + customText={translate('workspace.common.selected', {count: selectedCategoryKeys.length})} options={options} isSplitButton={false} style={[shouldDisplayButtonsInSeparateLine && styles.flexGrow1, shouldDisplayButtonsInSeparateLine && styles.mb3]} - isDisabled={!selectedCategoryNames.length} + isDisabled={!selectedCategoryKeys.length} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CATEGORIES.BULK_ACTIONS_DROPDOWN} testID="WorkspaceCategoriesPage-header-dropdown-menu-button" /> @@ -565,29 +547,20 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const selectionModeHeader = isMobileSelectionModeEnabled && shouldUseNarrowLayout; const headerContent = ( - <> - - {!hasSyncError && isConnectionVerified && currentConnectionName ? ( - - ) : ( - {translate('workspace.categories.subtitle')} - )} - - {categoryRows.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - + {!hasSyncError && isConnectionVerified && currentConnectionName ? ( + + ) : ( + {translate('workspace.categories.subtitle')} )} - + ); + const subtitleText = useMemo(() => { if (!policyHasAccountingConnections) { return {translate('workspace.categories.emptyCategories.subtitle')}; @@ -598,6 +571,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); }, [policyHasAccountingConnections, styles.renderHTML, styles.textAlignCenter, styles.alignItemsCenter, styles.textSupporting, styles.textNormal, translate, environmentURL, policyId]); + return ( )} - {!hasVisibleCategories && !isLoading && inputValue.length === 0 && ( + {!hasVisibleCategories && !isLoading && ( Date: Fri, 22 May 2026 12:34:57 -0400 Subject: [PATCH 40/73] fix alignment and width --- .../WorkspaceCategoriesTableRow.tsx | 12 +++++++----- .../Tables/WorkspaceCategoriesTable/index.tsx | 5 +++-- src/styles/variables.ts | 2 ++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 5e828988d422..416dd0a7e784 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -75,11 +75,13 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa
)} - + + + Date: Mon, 25 May 2026 13:42:49 -0400 Subject: [PATCH 41/73] hide approver col when no approver --- .../categories/WorkspaceCategoriesPage.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 54871786a632..1cab5d0b36ae 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -88,7 +88,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const [selectedCategoryKeys, setSelectedCategoryKeys] = useState([]); const canSelectMultiple = 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(); @@ -232,9 +231,20 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { [policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, updateWorkspaceCategoryEnabled], ); - const categoryRows = useMemo(() => { - const categories = Object.values(policyCategories ?? {}); + const categories = Object.values(policyCategories ?? {}); + const categoryApproverEmails: Record = {}; + + for (const category of categories) { + const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; + + if (approverEmail) { + categoryApproverEmails[category.name] = approverEmail; + } + } + + 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; @@ -242,7 +252,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return acc; } - const approverEmail = shouldShowApproverColumn ? (getCategoryApproverRule(policy?.rules?.approvalRules ?? [], value.name)?.approver ?? '') : ''; + const approverEmail = shouldShowApproverColumn ? categoryApproverEmails[value.name] : undefined; const approverPersonalDetail = getPersonalDetailByEmail(approverEmail); const {avatar: approverAvatar, displayName = approverEmail, accountID: approverAccountID} = approverPersonalDetail ?? {}; const approverDisplayName = displayName ? formatPhoneNumber(displayName) : ''; From c1834d8452ac10d565acb11fc08d1e3ac1f931dc Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 25 May 2026 14:26:22 -0400 Subject: [PATCH 42/73] disable row when the item is disabled --- .../WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 416dd0a7e784..2aab379a29c2 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -36,8 +36,8 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa return ( From d4490702be781aebfd849b1fe3206ae71726eb9e Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 25 May 2026 14:31:42 -0400 Subject: [PATCH 43/73] add domain list as tab bar page --- src/libs/Navigation/helpers/isTabRouteAtRoot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts index 10fad801949f..fda05ad1bffc 100644 --- a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts +++ b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts @@ -14,6 +14,7 @@ const SCREENS_WITH_TAB_BAR = new Set([ SCREENS.SETTINGS.ROOT, SCREENS.WORKSPACES_LIST, SCREENS.WORKSPACE.INITIAL, + SCREENS.DOMAINS_LIST, SCREENS.DOMAIN.INITIAL, ]); From 9593adc32c9e49336c8d568f87f6d8a117914514 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 25 May 2026 14:39:25 -0400 Subject: [PATCH 44/73] fix searchbar for company card one-off --- .../WorkspaceCompanyCardsTableHeaderButtons.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx index db55234f7608..9f4526c42a72 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableHeaderButtons.tsx @@ -154,7 +154,10 @@ function WorkspaceCompanyCardsTableHeaderButtons({policyID, feedName, isLoading, > {!isLoading && showTableControls && ( - + )} From dbaf2a17354043d2be0911238f699c4ba26d764c Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 25 May 2026 15:22:59 -0400 Subject: [PATCH 45/73] fix selection behavior --- src/components/Table/TableHeader.tsx | 14 ++++++++------ src/components/Table/TableRow.tsx | 1 + src/components/Table/middlewares/selection.ts | 15 ++++++++------- src/components/Table/types.ts | 3 +++ .../WorkspaceCategoriesTableRow.tsx | 4 ++-- .../Tables/WorkspaceCategoriesTable/index.tsx | 2 +- .../categories/WorkspaceCategoriesPage.tsx | 2 +- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 97bfcb4bdda5..b15cce3a14cf 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -73,13 +73,15 @@ function TableHeader {!!isSelectionCheckboxVisible && ( diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index fa00e3390e4e..86293ba21851 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -204,6 +204,7 @@ export default function TableRow({ {!!isSelectionCheckboxVisible && ( handleCheckboxPress(event as unknown as MouseEvent)} diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index e466c924bcc9..569488431069 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -28,20 +28,21 @@ type UseSelectionResult = MiddlewareHookResult({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const keyForLists = data.map((item) => item.keyForList); + const disabledKeyForLists = data.filter((item) => item.disabled).map((item) => item.keyForList); const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); - let areAllRowsSelected = true; + let areAllActiveRowsSelected = true; const modifiedData: Array> = []; for (const item of data) { const isSelected = selectedKeys.includes(item.keyForList); modifiedData.push({...item, selected: isSelected}); - if (!isSelected) { - areAllRowsSelected = false; + if (!isSelected && !item.disabled) { + areAllActiveRowsSelected = false; } } @@ -51,14 +52,14 @@ export default function useSelection({data, onRowSel */ const updateSelectedKeys = (action: (previousSelectedKeys: string[]) => string[]) => { setSelectedKeys((previousSelectedKeys) => { - const updatedValue = action(previousSelectedKeys); + const modifiedSelectedKeys = action(previousSelectedKeys).filter((key) => !disabledKeyForLists.includes(key)); if (onRowSelectionChange) { - const visibleSelectedRows = modifiedData.filter((row) => updatedValue.includes(row.keyForList)); + const visibleSelectedRows = modifiedData.filter((row) => modifiedSelectedKeys.includes(row.keyForList)); onRowSelectionChange(visibleSelectedRows); } - return updatedValue; + return modifiedSelectedKeys; }); }; @@ -74,7 +75,7 @@ export default function useSelection({data, onRowSel * rows in the table */ const handleSelectAll = () => { - if (areAllRowsSelected) { + if (areAllActiveRowsSelected) { updateSelectedKeys(() => []); } else { updateSelectedKeys(() => keyForLists); diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 90283e87eab1..2cf1b3adaaf9 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -12,6 +12,9 @@ import type {ActiveSorting, CompareItemsCallback, SortingMethods} from './middle 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; }; /** diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 2aab379a29c2..e155d9d82c1f 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -37,7 +37,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa return ( diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index c87215f30119..0a4be4a25369 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -19,7 +19,7 @@ type WorkspaceCategoryTableRowData = TableData & { approverAvatar?: AvatarSource; approverAccountID?: number; approverDisplayName?: string; - isDisabled: boolean; + disabled: boolean; errors?: OnyxCommon.Errors; pendingAction?: OnyxCommon.PendingAction; action: () => void; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 1cab5d0b36ae..1ea51ba824ac 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -261,7 +261,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { keyForList: value.name, name: getDecodedCategoryName(value.name), glCode: value['GL Code'], - isDisabled, + disabled: isDisabled, approverAvatar, approverAccountID, approverDisplayName, From 825cac84b124a05ebfa6a7ff54e3a9a6d184b572 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 26 May 2026 13:37:19 -0400 Subject: [PATCH 46/73] cleanup the selection logic --- src/components/Table/middlewares/selection.ts | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 569488431069..3a638a9c8c8d 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,3 +1,4 @@ +import type {Dispatch, SetStateAction} from 'react'; import {useRef, useState} from 'react'; import type {TableData, TableRow} from '@components/Table/types'; import type {MiddlewareHookResult} from './types'; @@ -27,22 +28,21 @@ type SelectionMethods = { type UseSelectionResult = MiddlewareHookResult>; export default function useSelection({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { - const keyForLists = data.map((item) => item.keyForList); - const disabledKeyForLists = data.filter((item) => item.disabled).map((item) => item.keyForList); - const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); const [selectedKeys, setSelectedKeys] = useState([]); - let areAllActiveRowsSelected = true; - const modifiedData: Array> = []; + const selectableKeys = data.filter((item) => !item.disabled).map((item) => item.keyForList); + + let areAllSelectableRowsSelected = true; + const tableRowData: Array> = []; for (const item of data) { const isSelected = selectedKeys.includes(item.keyForList); - modifiedData.push({...item, selected: isSelected}); + tableRowData.push({...item, selected: isSelected}); if (!isSelected && !item.disabled) { - areAllActiveRowsSelected = false; + areAllSelectableRowsSelected = false; } } @@ -50,16 +50,16 @@ export default function useSelection({data, onRowSel * Helper method to ensure that the row selection callback is called every time that the selected * keys are updated */ - const updateSelectedKeys = (action: (previousSelectedKeys: string[]) => string[]) => { - setSelectedKeys((previousSelectedKeys) => { - const modifiedSelectedKeys = action(previousSelectedKeys).filter((key) => !disabledKeyForLists.includes(key)); + const updateSelectedKeys: Dispatch> = (action) => { + setSelectedKeys((prevSelectedKeys) => { + const updatedSelectedKeys = typeof action === 'function' ? action(prevSelectedKeys) : action; if (onRowSelectionChange) { - const visibleSelectedRows = modifiedData.filter((row) => modifiedSelectedKeys.includes(row.keyForList)); - onRowSelectionChange(visibleSelectedRows); + const selectedRows = tableRowData.filter((row) => updatedSelectedKeys.includes(row.keyForList)); + onRowSelectionChange(selectedRows); } - return modifiedSelectedKeys; + return updatedSelectedKeys; }); }; @@ -67,7 +67,7 @@ export default function useSelection({data, onRowSel * Clear all of the currently selected keys */ const clearSelection = () => { - updateSelectedKeys(() => []); + updateSelectedKeys([]); }; /** @@ -75,23 +75,27 @@ export default function useSelection({data, onRowSel * rows in the table */ const handleSelectAll = () => { - if (areAllActiveRowsSelected) { - updateSelectedKeys(() => []); + if (areAllSelectableRowsSelected) { + updateSelectedKeys([]); } else { - updateSelectedKeys(() => keyForLists); + updateSelectedKeys(selectableKeys); } }; /** - * When a single row is selected in the table, update the selection state + * 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) => { - lastSelectedRowKeyRef.current = keyForList; - lastSelectedRowIsSelectedRef.current = !selectedKeys.includes(keyForList); - updateSelectedKeys((prevSelectedKeys) => { - if (prevSelectedKeys.includes(keyForList)) { - return prevSelectedKeys.filter((key) => key !== keyForList); + const keyIndex = prevSelectedKeys.indexOf(keyForList); + const isCurrentlySelected = keyIndex !== -1; + + lastSelectedRowKeyRef.current = keyForList; + lastSelectedRowIsSelectedRef.current = !isCurrentlySelected; + + if (isCurrentlySelected) { + return [...prevSelectedKeys.slice(0, keyIndex), ...prevSelectedKeys.slice(keyIndex + 1)]; } return [...prevSelectedKeys, keyForList]; @@ -103,7 +107,7 @@ export default function useSelection({data, onRowSel * the last selected row and the current row */ const handleMultipleRowSelection = (keyForList: string) => { - const keyForListExists = keyForLists.includes(keyForList); + const keyForListExists = selectableKeys.includes(keyForList); if (!keyForListExists) { return; @@ -117,22 +121,22 @@ export default function useSelection({data, onRowSel return; } - const currentSelectedRowIndex = keyForLists.indexOf(keyForList); - const lastSelectedRowIndex = keyForLists.indexOf(lastSelectedRowKey); + const currentSelectedRowIndex = selectableKeys.indexOf(keyForList); + const lastSelectedRowIndex = selectableKeys.indexOf(lastSelectedRowKey); if (currentSelectedRowIndex === -1 || lastSelectedRowIndex === -1) { handleSingleRowSelection(keyForList); return; } - const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); const endIndex = Math.max(currentSelectedRowIndex, lastSelectedRowIndex); + const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); updateSelectedKeys((prevSelectedKeys) => { const newSelectedKeys = [...prevSelectedKeys]; for (let i = startIndex; i <= endIndex; i++) { - const key = keyForLists.at(i); + const key = selectableKeys.at(i); if (!key) { continue; @@ -155,7 +159,7 @@ export default function useSelection({data, onRowSel }; const middleware = () => { - return modifiedData; + return tableRowData; }; return { From 5da43169e0b12da4143bc93b0adb9257c966c8d9 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 26 May 2026 14:09:33 -0400 Subject: [PATCH 47/73] cleanup code --- src/components/Table/TableHeader.tsx | 24 +++++++++---------- src/components/Table/TableRow.tsx | 10 ++++---- .../Tables/WorkspaceListTable/index.tsx | 6 ++--- .../Navigation/helpers/isTabRouteAtRoot.ts | 1 - .../categories/WorkspaceCategoriesPage.tsx | 23 ++++++++++-------- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index b15cce3a14cf..eb0ed539d9f6 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -34,7 +34,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 @@ -73,15 +73,15 @@ function TableHeader {!!isSelectionCheckboxVisible && ( {!!selectionEnabled && ( - - - + )} {columns.map((column) => { diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 86293ba21851..2afe3360a0b3 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -10,6 +10,7 @@ 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 useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -65,6 +66,7 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isMobileSelectionEnabled = useMobileSelectionMode(); const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled} = useTableContext(); @@ -153,7 +155,7 @@ export default function TableRow({ return; } - if (shouldUseNarrowLayout && selectionEnabled && isMobileSelectionEnabled) { + if (shouldUseNarrowLayout && isMobileSelectionEnabled) { handleCheckboxPress(event); return; } @@ -162,7 +164,7 @@ export default function TableRow({ }; const handleRowLongPress = () => { - if (!isInteractive) { + if (!isInteractive || !selectionEnabled || isMobileSelectionEnabled) { return; } @@ -204,11 +206,11 @@ export default function TableRow({ {!!isSelectionCheckboxVisible && ( handleCheckboxPress(event as unknown as MouseEvent)} - style={styles.flex1} /> )} {renderChildren(state)} diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index 7a677aea7246..8dde7df82a8d 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'; @@ -17,11 +17,9 @@ import WorkspaceRow from './WorkspaceTableRow'; type WorkspaceTableColumnKey = 'workspaces' | 'owner' | 'type' | 'actions'; -type WorkspaceRowData = { - keyForList: string; +type WorkspaceRowData = TableData & { title: string; icon: AvatarSource; - disabled: boolean; isDefault: boolean; isDeleted: boolean; isLoadingBill: boolean; diff --git a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts index 8924f4898659..62ad0dbbdc88 100644 --- a/src/libs/Navigation/helpers/isTabRouteAtRoot.ts +++ b/src/libs/Navigation/helpers/isTabRouteAtRoot.ts @@ -15,7 +15,6 @@ const SCREENS_WITH_TAB_BAR = new Set([ SCREENS.WORKSPACES_LIST, SCREENS.DOMAINS_LIST, SCREENS.WORKSPACE.INITIAL, - SCREENS.DOMAINS_LIST, SCREENS.DOMAIN.INITIAL, ]); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 1ea51ba824ac..71bf9412fd4b 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -232,15 +232,19 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); const categories = Object.values(policyCategories ?? {}); - const categoryApproverEmails: Record = {}; - for (const category of categories) { - const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; + const categoryApproverEmails = useMemo(() => { + const approverEmails: Record = {}; - if (approverEmail) { - categoryApproverEmails[category.name] = approverEmail; + for (const category of categories) { + const approverEmail = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category.name)?.approver; + + if (approverEmail) { + approverEmails[category.name] = approverEmail; + } } - } + return approverEmails; + }, [categories, policy?.rules?.approvalRules]); const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled && Object.keys(categoryApproverEmails).length > 0; @@ -275,12 +279,12 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return acc; }, []); - }, [policyCategories, isOffline, shouldShowApproverColumn, policy?.rules?.approvalRules, navigateToCategory, handleCategoryToggle, policyId]); + }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, navigateToCategory, handleCategoryToggle, policyId, policyCategories]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); - const handleCategorySelectionChange = (categories: WorkspaceCategoryTableRowData[]) => { - setSelectedCategoryKeys(categories.map((category) => category.keyForList)); + const handleCategorySelectionChange = (selectedCategories: WorkspaceCategoryTableRowData[]) => { + setSelectedCategoryKeys(selectedCategories.map((category) => category.keyForList)); }; const navigateToCategoriesSettings = useCallback(() => { @@ -391,7 +395,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); - // eslint-disable react-hooks/refs const getHeaderButtons = () => { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; From 458d26500296b1f65eff9e510aeefc97edc66b44 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 27 May 2026 13:52:19 -0400 Subject: [PATCH 48/73] add selection modal on mobile --- src/components/Table/Table.tsx | 36 +++++++++++++++++-- src/components/Table/TableRow.tsx | 3 +- src/components/Table/middlewares/selection.ts | 12 ++++++- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index e2e2b77ad8cd..c44af1df8b06 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -1,6 +1,12 @@ 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 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'; @@ -146,6 +152,9 @@ function Table) { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + if (!columns || columns.length === 0) { throw new Error('Table columns must be provided'); } @@ -161,7 +170,7 @@ function Table({compareItems, initialSortColumn}); const sortedData = sortMiddleware(searchedData); - const {middleware: selectionMiddleware, methods: selectionMethods} = useSelection({data: sortedData, onRowSelectionChange}); + const {middleware: selectionMiddleware, methods: selectionMethods, isMobileSelectionModalVisible} = useSelection({data: sortedData, onRowSelectionChange}); const processedData = selectionMiddleware(sortedData); const listRef = useRef>(null); @@ -208,6 +217,11 @@ function Table 0; const isEmptyResult = processedData.length === 0 && originalDataLength > 0 && (hasSearchString || hasActiveFilters); + const handleMobileSelectionPress = () => { + turnOnMobileSelectionMode(); + tableMethods.setIsMobileSelectionModalVisible(false); + }; + // eslint-disable-next-line react/jsx-no-constructed-context-values const contextValue: TableContextValue = { title, @@ -228,7 +242,25 @@ function Table}>{children}; + return ( + }> + {children} + + tableMethods.setIsMobileSelectionModalVisible(false)} + shouldPreventScrollOnFocus + > + + + + ); } export default Table; diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index b73863ad3947..6c1ef072b9cf 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -15,7 +15,6 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import useSkeletonSpan from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -169,7 +168,7 @@ export default function TableRow({ return; } - turnOnMobileSelectionMode(); + tableMethods.setIsMobileSelectionModalVisible(true); }; return ( diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 3a638a9c8c8d..f824a6799c38 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -23,14 +23,22 @@ type SelectionMethods = { /** Clear all of the currently selected rows in the table */ clearSelection: () => void; + + /** Set whether or not the mobile selection modal is visible */ + setIsMobileSelectionModalVisible: Dispatch>; }; -type UseSelectionResult = MiddlewareHookResult>; +type UseSelectionResult = MiddlewareHookResult> & { + /** Whether or not the mobile selection modal is visible */ + isMobileSelectionModalVisible: boolean; +}; export default function useSelection({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [isMobileSelectionModalVisible, setIsMobileSelectionModalVisible] = useState(false); const selectableKeys = data.filter((item) => !item.disabled).map((item) => item.keyForList); @@ -164,11 +172,13 @@ export default function useSelection({data, onRowSel return { middleware, + isMobileSelectionModalVisible, methods: { handleSelectAll, handleMultipleRowSelection, handleSingleRowSelection, clearSelection, + setIsMobileSelectionModalVisible, }, }; } From 736902efdb1ca8db6261441afd87450dcc1c39a6 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 28 May 2026 11:14:47 -0400 Subject: [PATCH 49/73] drill down selection props --- src/components/Table/Table.tsx | 5 +- src/components/Table/middlewares/selection.ts | 85 +++++++------------ src/components/Table/types.ts | 8 +- .../Tables/WorkspaceCategoriesTable/index.tsx | 6 +- .../categories/WorkspaceCategoriesPage.tsx | 22 ++--- 5 files changed, 51 insertions(+), 75 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index c44af1df8b06..fad2bd3ae87b 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -140,9 +140,10 @@ import type {TableData, TableHandle, TableMethods, TableProps} from './types'; function Table({ ref, title, - data = [], columns, filters, + data = [], + selectedKeys = [], compareItems, isItemInFilter, isItemInSearch, @@ -170,7 +171,7 @@ function Table({compareItems, initialSortColumn}); const sortedData = sortMiddleware(searchedData); - const {middleware: selectionMiddleware, methods: selectionMethods, isMobileSelectionModalVisible} = useSelection({data: sortedData, onRowSelectionChange}); + const {middleware: selectionMiddleware, methods: selectionMethods, isMobileSelectionModalVisible} = useSelection({data: sortedData, selectedKeys, onRowSelectionChange}); const processedData = selectionMiddleware(sortedData); const listRef = useRef>(null); diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index f824a6799c38..655ba484494f 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -7,8 +7,11 @@ 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?: (selectedRows: Array>) => void; + onRowSelectionChange?: (selectedRowKeys: string[]) => void; }; type SelectionMethods = { @@ -33,11 +36,9 @@ type UseSelectionResult = MiddlewareHookResult({data, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { +export default function useSelection({data, selectedKeys, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); - - const [selectedKeys, setSelectedKeys] = useState([]); const [isMobileSelectionModalVisible, setIsMobileSelectionModalVisible] = useState(false); const selectableKeys = data.filter((item) => !item.disabled).map((item) => item.keyForList); @@ -54,28 +55,11 @@ export default function useSelection({data, onRowSel } } - /** - * Helper method to ensure that the row selection callback is called every time that the selected - * keys are updated - */ - const updateSelectedKeys: Dispatch> = (action) => { - setSelectedKeys((prevSelectedKeys) => { - const updatedSelectedKeys = typeof action === 'function' ? action(prevSelectedKeys) : action; - - if (onRowSelectionChange) { - const selectedRows = tableRowData.filter((row) => updatedSelectedKeys.includes(row.keyForList)); - onRowSelectionChange(selectedRows); - } - - return updatedSelectedKeys; - }); - }; - /** * Clear all of the currently selected keys */ const clearSelection = () => { - updateSelectedKeys([]); + onRowSelectionChange?.([]); }; /** @@ -84,9 +68,9 @@ export default function useSelection({data, onRowSel */ const handleSelectAll = () => { if (areAllSelectableRowsSelected) { - updateSelectedKeys([]); + onRowSelectionChange?.([]); } else { - updateSelectedKeys(selectableKeys); + onRowSelectionChange?.(selectableKeys); } }; @@ -95,19 +79,18 @@ export default function useSelection({data, onRowSel * on or off */ const handleSingleRowSelection = (keyForList: string) => { - updateSelectedKeys((prevSelectedKeys) => { - const keyIndex = prevSelectedKeys.indexOf(keyForList); - const isCurrentlySelected = keyIndex !== -1; + const keyIndex = selectedKeys.indexOf(keyForList); + const isCurrentlySelected = keyIndex !== -1; - lastSelectedRowKeyRef.current = keyForList; - lastSelectedRowIsSelectedRef.current = !isCurrentlySelected; + lastSelectedRowKeyRef.current = keyForList; + lastSelectedRowIsSelectedRef.current = !isCurrentlySelected; - if (isCurrentlySelected) { - return [...prevSelectedKeys.slice(0, keyIndex), ...prevSelectedKeys.slice(keyIndex + 1)]; - } + if (isCurrentlySelected) { + onRowSelectionChange?.([...selectedKeys.slice(0, keyIndex), ...selectedKeys.slice(keyIndex + 1)]); + return; + } - return [...prevSelectedKeys, keyForList]; - }); + onRowSelectionChange?.([...selectedKeys, keyForList]); }; /** @@ -140,30 +123,28 @@ export default function useSelection({data, onRowSel const endIndex = Math.max(currentSelectedRowIndex, lastSelectedRowIndex); const startIndex = Math.min(currentSelectedRowIndex, lastSelectedRowIndex); - updateSelectedKeys((prevSelectedKeys) => { - const newSelectedKeys = [...prevSelectedKeys]; + const newSelectedKeys = [...selectedKeys]; - for (let i = startIndex; i <= endIndex; i++) { - const key = selectableKeys.at(i); + for (let i = startIndex; i <= endIndex; i++) { + const key = selectableKeys.at(i); - if (!key) { - continue; - } + 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); - } + if (lastSelectedRowIsSelected) { + if (!newSelectedKeys.includes(key)) { + newSelectedKeys.push(key); + } + } else { + const index = newSelectedKeys.indexOf(key); + if (index !== -1) { + newSelectedKeys.splice(index, 1); } } + } - return newSelectedKeys; - }); + onRowSelectionChange?.(newSelectedKeys); }; const middleware = () => { diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 6efead7b5a30..31e4cc2fa350 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -51,9 +51,6 @@ type TableColumn = { /** Optional styling configuration for the column. */ styling?: TableColumnStyling; - - /** Whether or not the column is sortable */ - sortable: boolean; }; type TableRow = DataType & { @@ -144,6 +141,9 @@ type TableProps>; /** Callback when an option is selected */ - onRowSelectionChange?: (selectedRows: Array>) => void; + onRowSelectionChange?: (selectedRowKeys: string[]) => void; }>; export type { diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 0a4be4a25369..903fb52a0053 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -31,10 +31,11 @@ type WorkspaceCategoriesTableProps = { ref?: React.Ref> | undefined; categories: WorkspaceCategoryTableRowData[]; shouldShowApproverColumn: boolean; - onRowSelectionChange: (selectedRows: WorkspaceCategoryTableRowData[]) => void; + selectedKeys: string[]; + onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -export default function WorkspaceCategoriesTable({ref, categories, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { +export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -120,6 +121,7 @@ export default function WorkspaceCategoriesTable({ref, categories, shouldShowApp compareItems={compareItems} isItemInSearch={isItemInSearch} renderItem={renderCategoryItem} + selectedKeys={selectedKeys} onRowSelectionChange={onRowSelectionChange} > {categories.length > CONST.STANDARD_LIST_ITEM_LIMIT && } diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 71bf9412fd4b..c38375db9057 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; @@ -12,8 +12,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import type {TableHandle} from '@components/Table'; -import type {WorkspaceCategoryTableColumnKey, WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; +import type {WorkspaceCategoryTableRowData} from '@components/Tables/WorkspaceCategoriesTable'; import WorkspaceCategoriesTable from '@components/Tables/WorkspaceCategoriesTable'; import Text from '@components/Text'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; @@ -61,7 +60,6 @@ type WorkspaceCategoriesPageProps = | PlatformStackScreenProps; function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { - const tableRef = useRef>(null); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); @@ -123,9 +121,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const clearTableSelection = useCallback(() => { - tableRef.current?.clearSelection(); - }, []); + const clearTableSelection = () => { + setSelectedCategoryKeys([]); + }; useCleanupSelectedOptions(clearTableSelection); @@ -283,10 +281,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); - const handleCategorySelectionChange = (selectedCategories: WorkspaceCategoryTableRowData[]) => { - setSelectedCategoryKeys(selectedCategories.map((category) => category.keyForList)); - }; - const navigateToCategoriesSettings = useCallback(() => { Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_SETTINGS.path)); }, [isQuickSettingsFlow]); @@ -620,10 +614,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Navigation.goBack(); }} > - {/* eslint-disable-next-line react-hooks/refs -- Ref is used in a callback when an action is taken */} {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} - {/* eslint-disable-next-line react-hooks/refs -- Ref is used in a callback when an action is taken */} {shouldDisplayButtonsInSeparateLine && {getHeaderButtons()}} {(!hasVisibleCategories || isLoading) && headerContent} {isLoading && ( @@ -635,10 +627,10 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { )} {hasVisibleCategories && !isLoading && ( setSelectedCategoryKeys(selectedRowKeys)} /> )} {!hasVisibleCategories && !isLoading && ( From ffa376551ebb866aad348bc3c19e0beb7875f1ed Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 28 May 2026 11:29:34 -0400 Subject: [PATCH 50/73] add bnback subtitles & locking for rows --- .../WorkspaceCategoriesTableRow.tsx | 1 + .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 30 ++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index e155d9d82c1f..9a8c4079bebd 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -79,6 +79,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 903fb52a0053..931bcc08b570 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -22,6 +22,7 @@ type WorkspaceCategoryTableRowData = TableData & { disabled: boolean; errors?: OnyxCommon.Errors; pendingAction?: OnyxCommon.PendingAction; + isLocked: boolean; action: () => void; dismissError: () => void; onToggleEnabled: (enabled: boolean) => void; diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c38375db9057..82da41eae38c 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -270,6 +270,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { enabled: value.enabled, errors: value.errors ?? undefined, pendingAction: value.pendingAction, + isLocked: isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [value]), action: () => navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), @@ -277,7 +278,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return acc; }, []); - }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, navigateToCategory, handleCategoryToggle, policyId, policyCategories]); + }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, policy, policyCategories, navigateToCategory, handleCategoryToggle, policyId]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); @@ -626,12 +627,27 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { /> )} {hasVisibleCategories && !isLoading && ( - setSelectedCategoryKeys(selectedRowKeys)} - /> + <> + + {!hasSyncError && isConnectionVerified && currentConnectionName ? ( + + ) : ( + {translate('workspace.categories.subtitle')} + )} + + + setSelectedCategoryKeys(selectedRowKeys)} + /> + )} {!hasVisibleCategories && !isLoading && ( From fb1c2405ba227301478fa38000ef9dda2b9e18b7 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 28 May 2026 13:04:56 -0400 Subject: [PATCH 51/73] fix styling differences --- src/components/Table/TableHeader.tsx | 2 +- src/components/Table/TableRow.tsx | 1 + src/styles/index.ts | 1 - src/styles/variables.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 4d6f54902a81..3217b04749fe 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -110,6 +110,7 @@ function TableHeader {!!isSelectionCheckboxVisible && ( toggleSorting(column.key)} > {!!isSelectionCheckboxVisible && ( height: 28, justifyContent: 'center', borderRadius: 20, - padding: 15, }, switchThumb: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 870db62f82d4..4e44315d20f4 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -126,7 +126,7 @@ export default { optionRowHeightCompact: 52, tableHeaderContentHeight: 20, tableRowHeight: 56, - tableRowHeightCompact: 72, + tableRowHeightCompact: 60, tableRowPaddingVertical: 8, tableRowPaddingHorizontal: 12, tableGroupRowPaddingVertical: 4, From 86899f28d6e8452b7de243229f1f4e763e023584 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 29 May 2026 10:25:10 -0400 Subject: [PATCH 52/73] make long pressing select hte right row --- src/components/Table/Table.tsx | 14 +++++++++----- src/components/Table/TableRow.tsx | 2 +- src/components/Table/middlewares/selection.ts | 13 ++++++++----- .../WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx | 3 ++- src/pages/workspace/rooms/WorkspaceRoomsPage.tsx | 1 + 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index fad2bd3ae87b..a7f690d5cce5 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -171,7 +171,7 @@ function Table({compareItems, initialSortColumn}); const sortedData = sortMiddleware(searchedData); - const {middleware: selectionMiddleware, methods: selectionMethods, isMobileSelectionModalVisible} = useSelection({data: sortedData, selectedKeys, onRowSelectionChange}); + const {middleware: selectionMiddleware, methods: selectionMethods, mobileSelectionModalRowKey} = useSelection({data: sortedData, selectedKeys, onRowSelectionChange}); const processedData = selectionMiddleware(sortedData); const listRef = useRef>(null); @@ -220,7 +220,11 @@ function Table { turnOnMobileSelectionMode(); - tableMethods.setIsMobileSelectionModalVisible(false); + + if (mobileSelectionModalRowKey) { + tableMethods.handleSingleRowSelection(mobileSelectionModalRowKey); + tableMethods.setMobileSelectionModalRowKey(null); + } }; // eslint-disable-next-line react/jsx-no-constructed-context-values @@ -248,10 +252,10 @@ function Table tableMethods.setIsMobileSelectionModalVisible(false)} shouldPreventScrollOnFocus + isVisible={!!mobileSelectionModalRowKey} + type={CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED} + onClose={() => tableMethods.setMobileSelectionModalRowKey(null)} > void; /** Set whether or not the mobile selection modal is visible */ - setIsMobileSelectionModalVisible: Dispatch>; + setMobileSelectionModalRowKey: Dispatch>; }; type UseSelectionResult = MiddlewareHookResult> & { /** Whether or not the mobile selection modal is visible */ - isMobileSelectionModalVisible: boolean; + mobileSelectionModalRowKey: string | null; }; export default function useSelection({data, selectedKeys, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); - const [isMobileSelectionModalVisible, setIsMobileSelectionModalVisible] = useState(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); @@ -153,13 +156,13 @@ export default function useSelection({data, selected return { middleware, - isMobileSelectionModalVisible, + mobileSelectionModalRowKey, methods: { handleSelectAll, handleMultipleRowSelection, handleSingleRowSelection, clearSelection, - setIsMobileSelectionModalVisible, + setMobileSelectionModalRowKey, }, }; } diff --git a/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx b/src/components/Tables/WorkspaceRoomsTable/WorkspaceRoomsTableRow.tsx index 8d6a2254d307..c29debf760f0 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/workspace/rooms/WorkspaceRoomsPage.tsx b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx index 45e451170ce6..3faa7bc8b825 100644 --- a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx +++ b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx @@ -59,6 +59,7 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { const rooms: WorkspaceRoomRowData[] = (policyReports ?? []).map((report) => { const ownerDetails = report.ownerAccountID ? personalDetails?.[report.ownerAccountID] : undefined; return { + keyForList: report.reportID, reportID: report.reportID, name: getReportName(report, reportAttributes), ownerAccountID: report.ownerAccountID, From b36f3fe6ded10e7ff6bd035ccd6d5b23227d5071 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 29 May 2026 11:10:36 -0400 Subject: [PATCH 53/73] add proper selection mode enabling/disabling --- src/components/Table/middlewares/selection.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index dfccfc80de10..c27db15daff5 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -1,6 +1,9 @@ import type {Dispatch, SetStateAction} from 'react'; -import {useRef, useState} 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 = { @@ -37,6 +40,8 @@ type UseSelectionResult = MiddlewareHookResult({data, selectedKeys, onRowSelectionChange}: UseSelectionProps): UseSelectionResult { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isSelectionModeEnabled = useMobileSelectionMode(); const lastSelectedRowKeyRef = useRef(null); const lastSelectedRowIsSelectedRef = useRef(false); @@ -45,18 +50,15 @@ export default function useSelection({data, selected 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)})); - let areAllSelectableRowsSelected = true; - const tableRowData: Array> = []; - - for (const item of data) { - const isSelected = selectedKeys.includes(item.keyForList); - tableRowData.push({...item, selected: isSelected}); - - if (!isSelected && !item.disabled) { - areAllSelectableRowsSelected = false; + useEffect(() => { + if (shouldUseNarrowLayout && !isSelectionModeEnabled && selectedKeys.length) { + turnOnMobileSelectionMode(); + } else if (!shouldUseNarrowLayout && isSelectionModeEnabled && !selectedKeys.length) { + turnOffMobileSelectionMode(); } - } + }, [shouldUseNarrowLayout, isSelectionModeEnabled, selectedKeys.length]); /** * Clear all of the currently selected keys @@ -70,6 +72,8 @@ export default function useSelection({data, selected * rows in the table */ const handleSelectAll = () => { + const areAllSelectableRowsSelected = selectableKeys.every((key) => selectedKeys.includes(key)); + if (areAllSelectableRowsSelected) { onRowSelectionChange?.([]); } else { From 1e12e5f32cfc0f2902242ef1c5d17dc5478027eb Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 29 May 2026 11:17:53 -0400 Subject: [PATCH 54/73] remove gl code col if there are no gl codes --- .../WorkspaceCategoriesTableRow.tsx | 7 +++++-- .../Tables/WorkspaceCategoriesTable/index.tsx | 20 ++++++++++++------- .../categories/WorkspaceCategoriesPage.tsx | 2 ++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 9a8c4079bebd..0a29155aacdf 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -24,11 +24,14 @@ type WorkspaceCategoriesTableRowProps = { /** 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, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { +export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTableLayout, shouldShowGLCodeColumn, shouldShowApproverColumn, item}: WorkspaceCategoriesTableRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -53,7 +56,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa {item.name} - {!shouldUseNarrowTableLayout && ( + {!shouldUseNarrowTableLayout && shouldShowGLCodeColumn && ( {item.glCode} diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 931bcc08b570..a22c02325577 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -31,12 +31,13 @@ type WorkspaceCategoryTableRowData = TableData & { type WorkspaceCategoriesTableProps = { ref?: React.Ref> | undefined; categories: WorkspaceCategoryTableRowData[]; + shouldShowGLCodeColumn: boolean; shouldShowApproverColumn: boolean; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { +export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, shouldShowGLCodeColumn, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -46,11 +47,15 @@ export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, label: translate('common.name'), sortable: true, }, - { - key: 'glCode', - label: translate('workspace.categories.glCode'), - sortable: true, - }, + ...(shouldShowGLCodeColumn + ? [ + { + key: 'glCode' as const, + label: translate('workspace.categories.glCode'), + sortable: true, + }, + ] + : []), ...(shouldShowApproverColumn ? [ { @@ -107,8 +112,9 @@ export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 82da41eae38c..faa358dd26b9 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -244,6 +244,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return approverEmails; }, [categories, policy?.rules?.approvalRules]); + const shouldShowGLCodeColumn = Object.values(policyCategories ?? {}).some((category) => !!category['GL Code']); const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled && Object.keys(categoryApproverEmails).length > 0; const categoryRows = useMemo(() => { @@ -644,6 +645,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategoryKeys(selectedRowKeys)} /> From 27a6359aeab9e72793e90fcfe30fab2204be6a34 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Fri, 29 May 2026 13:02:41 -0400 Subject: [PATCH 55/73] fix long selection --- src/components/Table/TableRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index e82a4380f623..c9ef3d47a35c 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -164,7 +164,7 @@ export default function TableRow({ }; const handleRowLongPress = () => { - if (!isInteractive || !selectionEnabled || isMobileSelectionEnabled) { + if (!isInteractive || !selectionEnabled || isMobileSelectionEnabled || !shouldUseNarrowLayout) { return; } From 4d2efe4526398ca948a9efbbe81f55c935e2594d Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 2 Jun 2026 12:04:42 -0400 Subject: [PATCH 56/73] fix react compiler errors --- src/components/Table/TableHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 3217b04749fe..2858cfd3ddb8 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -80,8 +80,8 @@ function TableHeader Date: Wed, 3 Jun 2026 10:00:37 -0400 Subject: [PATCH 57/73] use tokenized searhc, accessibility, selection negation --- src/components/Table/middlewares/selection.ts | 2 +- .../WorkspaceCategoriesTableRow.tsx | 10 ++++++++++ .../Tables/WorkspaceCategoriesTable/index.tsx | 6 ++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index c27db15daff5..a8fec942dc60 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -74,7 +74,7 @@ export default function useSelection({data, selected const handleSelectAll = () => { const areAllSelectableRowsSelected = selectableKeys.every((key) => selectedKeys.includes(key)); - if (areAllSelectableRowsSelected) { + if (!areAllSelectableRowsSelected) { onRowSelectionChange?.([]); } else { onRowSelectionChange?.(selectableKeys); diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 0a29155aacdf..ceec8ce71b0a 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -37,10 +37,20 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa 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 ( = (item, searchValue) => { const searchLower = searchValue.toLowerCase(); - return !!item.name.toLowerCase().includes(searchLower) || !!item.glCode?.toLowerCase().includes(searchLower); + const results = tokenizedSearch([item], searchLower, (option) => [option.name, option.glCode ?? '']); + return results.length > 0; }; const renderCategoryItem = ({item, index}: ListRenderItemInfo) => ( @@ -131,7 +133,7 @@ export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, selectedKeys={selectedKeys} onRowSelectionChange={onRowSelectionChange} > - {categories.length > CONST.STANDARD_LIST_ITEM_LIMIT && } + {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } From d65b7ff1414aacec1c346ae4aee431a693db1e91 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Wed, 3 Jun 2026 10:15:06 -0400 Subject: [PATCH 58/73] fix selection mode bug --- src/components/Table/middlewares/selection.ts | 18 +++++++++++++++--- .../WorkspaceCategoriesTableRow.tsx | 9 ++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index a8fec942dc60..39bf5c466822 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -52,6 +52,7 @@ export default function useSelection({data, selected 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(); @@ -60,6 +61,13 @@ export default function useSelection({data, selected } }, [shouldUseNarrowLayout, isSelectionModeEnabled, selectedKeys.length]); + // When there are no more items to be selected, turn of selection mode on mobile + useEffect(() => { + if (!selectableKeys.length && isSelectionModeEnabled) { + turnOffMobileSelectionMode(); + } + }, [selectableKeys.length, isSelectionModeEnabled]); + /** * Clear all of the currently selected keys */ @@ -74,10 +82,14 @@ export default function useSelection({data, selected const handleSelectAll = () => { const areAllSelectableRowsSelected = selectableKeys.every((key) => selectedKeys.includes(key)); - if (!areAllSelectableRowsSelected) { - onRowSelectionChange?.([]); - } else { + const isSelectionEmpty = selectedKeys.length === 0; + const isSelectionFull = areAllSelectableRowsSelected; + const isSelectionIndeterminate = selectedKeys.length > 0 && !areAllSelectableRowsSelected; + + if (isSelectionEmpty) { onRowSelectionChange?.(selectableKeys); + } else if (isSelectionFull || isSelectionIndeterminate) { + onRowSelectionChange?.([]); } }; diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index ceec8ce71b0a..59f7428541d9 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -63,12 +63,12 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa {({hovered}) => ( <> - {item.name} + {item.name} {!shouldUseNarrowTableLayout && shouldShowGLCodeColumn && ( - {item.glCode} + {item.glCode} )} @@ -82,7 +82,10 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa type={CONST.ICON_TYPE_AVATAR} size={CONST.AVATAR_SIZE.MID_SUBSCRIPT} /> - + )} From 7b10b01f8d9ef19e39f4709bd14ff628d1bde936 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 10:58:33 -0400 Subject: [PATCH 59/73] address comments --- Mobile-Expensify | 2 +- src/components/Table/middlewares/selection.ts | 2 +- src/components/Table/types.ts | 2 +- .../WorkspaceCategoriesTableRow.tsx | 6 +++++- src/components/Tables/WorkspaceCategoriesTable/index.tsx | 5 +++-- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 6 +++--- 6 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 6f3f8d5a0ef6..59644ae54601 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6f3f8d5a0ef6a0a640d2d4667a2e89c62eaa3ae4 +Subproject commit 59644ae546013d4f71729440245cfde4b7a395b6 diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 39bf5c466822..4c42bbc5ab53 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -61,7 +61,7 @@ export default function useSelection({data, selected } }, [shouldUseNarrowLayout, isSelectionModeEnabled, selectedKeys.length]); - // When there are no more items to be selected, turn of selection mode on mobile + // When there are no more items to be selected, turn off selection mode on mobile useEffect(() => { if (!selectableKeys.length && isSelectionModeEnabled) { turnOffMobileSelectionMode(); diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 31e4cc2fa350..aa4796c3809c 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -78,7 +78,7 @@ type TableMethods = FlashListRef & TableMethods & { /** Method to get all of the processed data after filtering, searching, and sorting have been applied. */ - getProcessedData: () => DataType[]; + getProcessedData: () => TableRow[]; }; /** diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 59f7428541d9..881a8a562c7c 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -50,6 +50,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa ( <> - {item.name} + {!shouldUseNarrowTableLayout && shouldShowGLCodeColumn && ( diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 9eaecfc00cf6..3e4449d4a64b 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -1,7 +1,7 @@ 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 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 tokenizedSearch from '@libs/tokenizedSearch'; @@ -131,6 +131,7 @@ export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, isItemInSearch={isItemInSearch} renderItem={renderCategoryItem} selectedKeys={selectedKeys} + keyExtractor={(category) => category.keyForList} onRowSelectionChange={onRowSelectionChange} > {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 8dea4fcd093c..bf17df4790ec 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -123,9 +123,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const clearTableSelection = () => { - setSelectedCategoryKeys([]); - }; + const clearTableSelection = useCallback(() => { + setSelectedCategoryKeys((prevSelectedCategoryKeys) => (prevSelectedCategoryKeys.length > 0 ? [] : prevSelectedCategoryKeys)); + }, []); useCleanupSelectedOptions(clearTableSelection); From a3212be288872cd140e156879b531a62eb895ca4 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 11:05:27 -0400 Subject: [PATCH 60/73] fix tooltip --- .../WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 881a8a562c7c..d0967963f365 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -65,6 +65,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa <> From 0ec0fb866ca166ccbd0aa1bff9813a30990fcdf2 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 11:35:19 -0400 Subject: [PATCH 61/73] handle the switch for non workspace admins --- .../WorkspaceCategoriesTableRow.tsx | 3 +-- .../Tables/WorkspaceCategoriesTable/index.tsx | 13 +++++++++++-- .../categories/WorkspaceCategoriesPage.tsx | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index d0967963f365..ddf2db6ca4c8 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -99,7 +99,6 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 3e4449d4a64b..653d30ffbaed 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -32,13 +32,22 @@ type WorkspaceCategoryTableRowData = TableData & { type WorkspaceCategoriesTableProps = { ref?: React.Ref> | undefined; categories: WorkspaceCategoryTableRowData[]; + selectionEnabled: boolean; shouldShowGLCodeColumn: boolean; shouldShowApproverColumn: boolean; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; }; -export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, shouldShowGLCodeColumn, shouldShowApproverColumn, onRowSelectionChange}: WorkspaceCategoriesTableProps) { +export default function WorkspaceCategoriesTable({ + ref, + categories, + selectedKeys, + selectionEnabled, + shouldShowGLCodeColumn, + shouldShowApproverColumn, + onRowSelectionChange, +}: WorkspaceCategoriesTableProps) { const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -123,8 +132,8 @@ export default function WorkspaceCategoriesTable({ref, categories, selectedKeys, return ( { + if (!canWriteCategories) { + showReadOnlyModal(); + return; + } + if (isDisablingOrDeletingLastEnabledCategory(policy, policyCategories, [category])) { showCannotDeleteOrDisableLastCategoryModal(); return; @@ -662,6 +667,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Date: Thu, 4 Jun 2026 11:40:59 -0400 Subject: [PATCH 62/73] reset mobile exfy --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 59644ae54601..671bb4c74be7 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 59644ae546013d4f71729440245cfde4b7a395b6 +Subproject commit 671bb4c74be77f51f7fa6a9efb6c15a5ba4fc605 From a2fe057f719dfb4f158b22668bb8ede1486f0552 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 11:54:25 -0400 Subject: [PATCH 63/73] fix lint --- .../workspace/categories/WorkspaceCategoriesPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 90d36c1357a7..9486596630ab 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -83,7 +83,7 @@ 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 [selectedCategoryKeys, setSelectedCategoryKeys] = useState([]); const canSelectMultiple = canWriteCategories && (isSmallScreenWidth ? isMobileSelectionModeEnabled : true); @@ -239,7 +239,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { updateWorkspaceCategoryEnabled(enabled, category.name); }, - [policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, updateWorkspaceCategoryEnabled], + [canWriteCategories, policy, policyCategories, showCannotDeleteOrDisableLastCategoryModal, showReadOnlyModal, updateWorkspaceCategoryEnabled], ); const categories = Object.values(policyCategories ?? {}); @@ -292,7 +292,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { return acc; }, []); - }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, policy, policyCategories, navigateToCategory, handleCategoryToggle, policyId]); + }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, canWriteCategories, policy, policyCategories, navigateToCategory, handleCategoryToggle, policyId]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); @@ -642,7 +642,9 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { {!shouldDisplayButtonsInSeparateLine && getHeaderButtons()} {shouldDisplayButtonsInSeparateLine && !!getHeaderButtons() && {getHeaderButtons()}} + {(!hasVisibleCategories || isLoading) && headerContent} + {isLoading && ( )} + {hasVisibleCategories && !isLoading && ( <> From 846dd6c3c020024fd1242200d957cef6185311b5 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 14:12:55 -0400 Subject: [PATCH 64/73] fix eslint --- src/components/Table/middlewares/selection.ts | 6 ++++-- src/components/Table/types.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/Table/middlewares/selection.ts b/src/components/Table/middlewares/selection.ts index 4c42bbc5ab53..3bfefd6efff1 100644 --- a/src/components/Table/middlewares/selection.ts +++ b/src/components/Table/middlewares/selection.ts @@ -63,9 +63,11 @@ export default function useSelection({data, selected // When there are no more items to be selected, turn off selection mode on mobile useEffect(() => { - if (!selectableKeys.length && isSelectionModeEnabled) { - turnOffMobileSelectionMode(); + if (selectableKeys.length || !isSelectionModeEnabled) { + return; } + + turnOffMobileSelectionMode(); }, [selectableKeys.length, isSelectionModeEnabled]); /** diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index aa4796c3809c..834fad6a2ed9 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -78,7 +78,7 @@ type TableMethods = FlashListRef & TableMethods & { /** Method to get all of the processed data after filtering, searching, and sorting have been applied. */ - getProcessedData: () => TableRow[]; + getProcessedData: () => Array>; }; /** From 41f2e34c11fd89e22f474137dd6c897d5265e03b Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 4 Jun 2026 14:26:17 -0400 Subject: [PATCH 65/73] fix conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 671bb4c74be7..516462c76874 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 671bb4c74be77f51f7fa6a9efb6c15a5ba4fc605 +Subproject commit 516462c768747fc72b540b20dfa456f83b173a19 From be6184b1fd88a26c7aae32629411a1a2fc931c0c Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 8 Jun 2026 09:35:00 -0400 Subject: [PATCH 66/73] fix selection when all disabled, fix cursor, fix sentry label --- src/CONST/index.ts | 1 + src/components/Table/TableHeader.tsx | 7 +++--- src/components/Table/TableRow.tsx | 23 +++++++++++-------- .../WorkspaceCategoriesTableRow.tsx | 14 +++++++---- .../Tables/WorkspaceCategoriesTable/index.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 5 +--- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ac256093b7e8..456788e04d12 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9026,6 +9026,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/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 2858cfd3ddb8..d39f64c796c4 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -73,15 +73,16 @@ function TableHeader !row.disabled); let isSelectionIndeterminate = false; - let isEverySelectableRowSelected = true; + 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 processedData) { + for (const row of selectableRows) { isSelectionIndeterminate = row.selected || isSelectionIndeterminate; - isEverySelectableRowSelected = !!(row.selected || row.disabled) && isEverySelectableRowSelected; + isEverySelectableRowSelected = row.selected && isEverySelectableRowSelected; } } diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index c9ef3d47a35c..7588ba7a3d41 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -28,6 +28,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; @@ -51,6 +54,7 @@ export default function TableRow({ children, accessible, rowIndex, + disabled, sentryLabel, interactive, isLoading, @@ -74,7 +78,7 @@ export default function TableRow({ const rowCount = processedData.length; const isFirstRow = rowIndex === 0; const isLastRow = rowIndex === rowCount - 1; - const isInteractive = interactive && !isLoading; + const isDisabled = !!disabled || !!isLoading; const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr')); const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); @@ -95,7 +99,7 @@ export default function TableRow({ const tableRowPressableStyles = [ styles.mh5, styles.highlightBG, - isInteractive && styles.userSelectNone, + styles.userSelectNone, !isFirstRow && styles.borderTop, isLastRow && styles.tableBottomRadius, item.selected && [styles.activeComponentBG, {borderColor: theme.buttonHoveredBG}], @@ -124,7 +128,7 @@ export default function TableRow({ ]; const tableRowPressableHoverStyle = (() => { - if (!isInteractive) { + if (isDisabled || !interactive) { return undefined; } if (item.selected) { @@ -151,7 +155,7 @@ export default function TableRow({ }; const handleRowPress = (event?: MouseEvent) => { - if (!isInteractive) { + if (isDisabled || !interactive) { return; } @@ -164,7 +168,7 @@ export default function TableRow({ }; const handleRowLongPress = () => { - if (!isInteractive || !selectionEnabled || isMobileSelectionEnabled || !shouldUseNarrowLayout) { + if (isDisabled || !selectionEnabled || isMobileSelectionEnabled || !shouldUseNarrowLayout || !interactive) { return; } @@ -181,12 +185,13 @@ export default function TableRow({ accessibilityLabel="row" style={tableRowPressableStyles} sentryLabel={sentryLabel} - interactive={isInteractive} + interactive={interactive} + disabled={isDisabled} hoverStyle={tableRowPressableHoverStyle} - pressDimmingValue={isInteractive ? undefined : 1} - role={isInteractive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} - onLongPress={handleRowLongPress} + pressDimmingValue={!interactive ? undefined : 1} + role={interactive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} onPress={(event) => handleRowPress(event as unknown as MouseEvent)} + onLongPress={handleRowLongPress} {...props} > {(state) => ( diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index ddf2db6ca4c8..5af49bf5f29f 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -4,7 +4,6 @@ import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; import Switch from '@components/Switch'; import Table from '@components/Table'; -import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -48,11 +47,12 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa return ( - {item.glCode} + )} @@ -88,8 +92,9 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa size={CONST.AVATAR_SIZE.MID_SUBSCRIPT} /> )} @@ -100,6 +105,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 653d30ffbaed..c0980137e164 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -133,6 +133,7 @@ export default function WorkspaceCategoriesTable({
navigateToCategory(value), onToggleEnabled: (enabled: boolean) => handleCategoryToggle(enabled, value), dismissError: () => clearCategoryErrors(policyId, value.name, policyCategories), @@ -294,8 +293,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); }, [categories, isOffline, shouldShowApproverColumn, categoryApproverEmails, canWriteCategories, policy, policyCategories, navigateToCategory, handleCategoryToggle, policyId]); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryRows); - const navigateToCategoriesSettings = useCallback(() => { Navigation.navigate(createDynamicRoute(isQuickSettingsFlow ? DYNAMIC_ROUTES.SETTINGS_CATEGORIES_SETTINGS.path : DYNAMIC_ROUTES.WORKSPACE_CATEGORIES_SETTINGS.path)); }, [isQuickSettingsFlow]); From 2c2d6efea0038564293be6674902f7fcb0480afa Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Mon, 8 Jun 2026 11:16:13 -0400 Subject: [PATCH 67/73] remove margin, left align --- src/components/Table/TableHeader.tsx | 6 +++--- .../WorkspaceCategoriesTableRow.tsx | 2 +- src/components/Tables/WorkspaceCategoriesTable/index.tsx | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index d39f64c796c4..8b1b23b17a92 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -163,6 +163,7 @@ function TableHeader({column}: {column: TableColumn}) { const theme = useTheme(); + const toggleCount = useRef(0); const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowUpLong', 'ArrowDownLong']); @@ -170,11 +171,10 @@ function TableHeaderColumn(); + 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). @@ -212,7 +212,7 @@ function TableHeaderColumn {column.label} diff --git a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx index 5af49bf5f29f..e4e656a80eed 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/WorkspaceCategoriesTableRow.tsx @@ -101,7 +101,7 @@ export default function WorkspaceCategoriesTableRow({rowIndex, shouldUseNarrowTa )} - + Date: Mon, 8 Jun 2026 14:40:25 -0400 Subject: [PATCH 68/73] fix react compliance check --- .../categories/WorkspaceCategoriesPage.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 3a4f40bea56c..202a64acd78b 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -600,7 +600,18 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { /> ); - }, [policyHasAccountingConnections, styles.renderHTML, styles.textAlignCenter, styles.alignItemsCenter, styles.textSupporting, styles.textNormal, translate, environmentURL, policyId]); + }, [ + policyHasAccountingConnections, + styles.renderHTML, + styles.textAlignCenter, + styles.alignItemsCenter, + styles.textSupporting, + styles.textNormal, + translate, + environmentURL, + policyId, + canWriteCategories, + ]); return ( Date: Tue, 9 Jun 2026 08:30:59 -0400 Subject: [PATCH 69/73] fix comments --- src/components/Table/Table.tsx | 3 +++ src/components/Table/TableContext.tsx | 3 +++ src/components/Table/TableHeader.tsx | 4 +--- src/components/Table/TableRow.tsx | 5 ++--- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 4 ++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index a7f690d5cce5..89d86d71f8a8 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -4,6 +4,7 @@ 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'; @@ -155,6 +156,7 @@ function Table) { const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const isMobileSelectionEnabled = useMobileSelectionMode(); if (!columns || columns.length === 0) { throw new Error('Table columns must be provided'); @@ -245,6 +247,7 @@ function Table(); + const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData, isMobileSelectionEnabled} = useTableContext(); const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout); if (shouldUseNarrowTableLayout && !title) { diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 7588ba7a3d41..7dfa462c8575 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -11,7 +11,6 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLocalize from '@hooks/useLocalize'; -import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -71,8 +70,7 @@ export default function TableRow({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isMobileSelectionEnabled = useMobileSelectionMode(); - const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled} = useTableContext(); + const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, isMobileSelectionEnabled} = useTableContext(); const item = processedData.at(rowIndex); const rowCount = processedData.length; @@ -211,6 +209,7 @@ export default function TableRow({ {!!isSelectionCheckboxVisible && ( !!category['GL Code']); + const shouldShowGLCodeColumn = Object.values(policyCategories ?? {}).some((category) => !!category['GL Code']) && isControlPolicyWithWideLayout; const shouldShowApproverColumn = isControlPolicyWithWideLayout && !!policy?.areRulesEnabled && Object.keys(categoryApproverEmails).length > 0; const categoryRows = useMemo(() => { @@ -276,7 +276,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { keyForList: value.name, name: getDecodedCategoryName(value.name), glCode: value['GL Code'], - disabled: isDisabled || !canWriteCategories, + disabled: isDisabled, approverAvatar, approverAccountID, approverDisplayName, From dbfef71d848c50d8be380bff076db45d68f09680 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 9 Jun 2026 08:32:43 -0400 Subject: [PATCH 70/73] fix eslint --- src/components/Table/TableRow.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 7dfa462c8575..7421699b5fcb 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -1,5 +1,5 @@ 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'; @@ -143,8 +143,8 @@ export default function TableRow({ return children; }; - const handleCheckboxPress = (event?: MouseEvent) => { - if (event && event.shiftKey) { + const handleCheckboxPress = (event?: GestureResponderEvent | KeyboardEvent | undefined) => { + if (event && 'shiftKey' in event && event.shiftKey) { tableMethods.handleMultipleRowSelection(item.keyForList); return; } @@ -152,7 +152,7 @@ export default function TableRow({ tableMethods.handleSingleRowSelection(item.keyForList); }; - const handleRowPress = (event?: MouseEvent) => { + const handleRowPress = (event?: GestureResponderEvent | KeyboardEvent | undefined) => { if (isDisabled || !interactive) { return; } @@ -188,7 +188,7 @@ export default function TableRow({ hoverStyle={tableRowPressableHoverStyle} pressDimmingValue={!interactive ? undefined : 1} role={interactive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} - onPress={(event) => handleRowPress(event as unknown as MouseEvent)} + onPress={(event) => handleRowPress(event)} onLongPress={handleRowLongPress} {...props} > @@ -215,7 +215,7 @@ export default function TableRow({ disabled={item.disabled} isChecked={!!item.selected} accessibilityLabel={translate('common.select')} - onPress={(event) => handleCheckboxPress(event as unknown as MouseEvent)} + onPress={(event) => handleCheckboxPress(event)} /> )} {renderChildren(state)} From c81051da79280d470e87a2ba5d22fcea527d8ccf Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 9 Jun 2026 11:08:46 -0400 Subject: [PATCH 71/73] fix type errors --- src/components/Table/TableContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 3c2cae8d94b9..61ce34cbc338 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -82,6 +82,7 @@ const defaultTableContextValue: TableContextValue = { hasSearchString: false, isEmptyResult: false, shouldUseNarrowTableLayout: false, + isMobileSelectionEnabled: false, }; const TableContext = createContext(defaultTableContextValue); From 5e2ab08cfe01d4d053adb4d17a2add062e143bd5 Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Tue, 9 Jun 2026 12:50:51 -0400 Subject: [PATCH 72/73] update to use can write --- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 871d28d6658d..c4373338c8a9 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -680,7 +680,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { Date: Tue, 9 Jun 2026 13:43:06 -0400 Subject: [PATCH 73/73] fix selection --- .../Tables/WorkspaceCategoriesTable/index.tsx | 15 +++- .../categories/WorkspaceCategoriesPage.tsx | 84 ++++++++++--------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/src/components/Tables/WorkspaceCategoriesTable/index.tsx b/src/components/Tables/WorkspaceCategoriesTable/index.tsx index 9d597d2dc9bd..8309fcda77f2 100644 --- a/src/components/Tables/WorkspaceCategoriesTable/index.tsx +++ b/src/components/Tables/WorkspaceCategoriesTable/index.tsx @@ -38,6 +38,7 @@ type WorkspaceCategoriesTableProps = { shouldShowApproverColumn: boolean; selectedKeys: string[]; onRowSelectionChange: (selectedRowKeys: string[]) => void; + EmptyStateComponent: React.ReactElement; }; export default function WorkspaceCategoriesTable({ @@ -48,6 +49,7 @@ export default function WorkspaceCategoriesTable({ shouldShowGLCodeColumn, shouldShowApproverColumn, onRowSelectionChange, + EmptyStateComponent, }: WorkspaceCategoriesTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -134,6 +136,8 @@ export default function WorkspaceCategoriesTable({ /> ); + const isEmpty = categories.length === 0; + return (
category.keyForList} onRowSelectionChange={onRowSelectionChange} > - {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } - - + {isEmpty && EmptyStateComponent} + {!isEmpty && ( + <> + {categories.length >= CONST.STANDARD_LIST_ITEM_LIMIT && } + + + + )}
); } diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index c4373338c8a9..3e5e4626d8a8 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -613,6 +613,34 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { canWriteCategories, ]); + const emptyStateContent = ( + + + + ); + return ( )} - {hasVisibleCategories && !isLoading && ( + {!isLoading && ( <> - - {!hasSyncError && isConnectionVerified && currentConnectionName ? ( - - ) : ( - {translate('workspace.categories.subtitle')} - )} - + {hasVisibleCategories && ( + + {!hasSyncError && isConnectionVerified && currentConnectionName ? ( + + ) : ( + {translate('workspace.categories.subtitle')} + )} + + )} setSelectedCategoryKeys(selectedRowKeys)} + EmptyStateComponent={emptyStateContent} /> )} - {!hasVisibleCategories && !isLoading && ( - - - - )}