Skip to content

Commit 57e33da

Browse files
authored
Merge pull request #95224 from Expensify/claude-migrateGroupMembersTable
Migrate group members page to the new Table layout
2 parents 8f9ac4c + a0d5534 commit 57e33da

10 files changed

Lines changed: 514 additions & 179 deletions

File tree

src/components/Table/Table.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
160160
narrowLayoutSortColumn,
161161
children,
162162
selectionEnabled,
163+
shouldEnableSelectionInNarrowPaneModal,
163164
onRowSelectionChange,
164165
...listProps
165166
}: TableProps<DataType, ColumnKey, FilterKey>) {
@@ -197,7 +198,7 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
197198
methods: selectionMethods,
198199
mobileSelectionModalRowKey,
199200
middleware: selectionMiddleware,
200-
} = useSelection<DataType>({data: sortedData, originalSelectableCount, currentFilters, selectedKeys, onRowSelectionChange});
201+
} = useSelection<DataType>({data: sortedData, originalSelectableCount, currentFilters, selectedKeys, onRowSelectionChange, shouldEnableSelectionInNarrowPaneModal});
201202
const selectionData = selectionMiddleware(sortedData);
202203

203204
const {methods: highlightingMethods, middleware: highlightMiddleware} = useHighlighting<DataType>();
@@ -263,6 +264,7 @@ function Table<DataType extends TableData, ColumnKey extends string = string, Fi
263264
isEmptyResult,
264265
shouldUseNarrowTableLayout,
265266
selectionEnabled,
267+
shouldEnableSelectionInNarrowPaneModal,
266268
isMobileSelectionEnabled,
267269
};
268270

src/components/Table/TableContext.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type TableContextValue<DataType extends TableData, ColumnKey extends string = st
2626
/** Whether or not selection is enabled for the table */
2727
selectionEnabled?: boolean;
2828

29+
/** Whether the selection UX should key off the real screen size instead of shouldUseNarrowLayout (for tables inside a narrow pane modal / RHP) */
30+
shouldEnableSelectionInNarrowPaneModal?: boolean;
31+
2932
/** The data array after filtering, searching, and sorting have been applied. */
3033
processedData: Array<TableRow<DataType>>;
3134

src/components/Table/TableHeader.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,14 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
6060
const theme = useTheme();
6161
const styles = useThemeStyles();
6262
const {translate} = useLocalize();
63-
const {shouldUseNarrowLayout} = useResponsiveLayout();
64-
const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData, isMobileSelectionEnabled} = useTableContext<DataType, ColumnKey>();
65-
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout);
63+
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
64+
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
65+
const {columns, isEmptyResult, title, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, processedData, isMobileSelectionEnabled, shouldEnableSelectionInNarrowPaneModal} =
66+
useTableContext<DataType, ColumnKey>();
67+
// Tables inside a narrow pane modal (RHP) opt into keying the header checkbox off the real screen size, since
68+
// shouldUseNarrowLayout is always true in an RHP. Other tables keep the original behavior. Visual padding below still uses shouldUseNarrowLayout.
69+
const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout;
70+
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !selectionUsesNarrowLayout);
6671

6772
if (shouldUseNarrowTableLayout && !title) {
6873
return null;

src/components/Table/TableRow.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,20 @@ export default function TableRow({
6262
const theme = useTheme();
6363
const styles = useThemeStyles();
6464
const {translate} = useLocalize();
65-
const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
66-
const shouldEnableMobileSelectionLongPress = shouldUseNarrowLayout && !isInNarrowPaneModal;
67-
const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, isMobileSelectionEnabled} = useTableContext();
65+
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
66+
const {isSmallScreenWidth, shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
67+
const {processedData, columns, shouldUseNarrowTableLayout, tableMethods, selectionEnabled, isMobileSelectionEnabled, shouldEnableSelectionInNarrowPaneModal = false} = useTableContext();
68+
69+
// Tables inside a narrow pane modal (RHP) opt into keying the selection UX off the real screen size (isSmallScreenWidth),
70+
// because shouldUseNarrowLayout is always true in an RHP and would otherwise suppress selection entirely. All other
71+
// tables keep the original shouldUseNarrowLayout behavior. Visual layout still uses shouldUseNarrowTableLayout.
72+
const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout;
73+
const shouldEnableMobileSelectionLongPress = isSmallScreenWidth && (shouldEnableSelectionInNarrowPaneModal || !isInNarrowPaneModal);
6874

6975
const item = processedData.at(rowIndex);
7076
const rowCount = processedData.length;
7177
const gridTemplateColumns = columns.map((column) => (column.width ? `${column.width}px` : '1fr'));
72-
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !shouldUseNarrowLayout);
78+
const isSelectionCheckboxVisible = selectionEnabled && (isMobileSelectionEnabled || !selectionUsesNarrowLayout);
7379

7480
const isDisabled = !!disabled;
7581
const isFirstRow = rowIndex === 0;
@@ -151,7 +157,7 @@ export default function TableRow({
151157
return;
152158
}
153159

154-
if (!shouldUseNarrowLayout || !isMobileSelectionEnabled || !selectionEnabled) {
160+
if (!selectionUsesNarrowLayout || !isMobileSelectionEnabled || !selectionEnabled) {
155161
onPress?.();
156162
return;
157163
}

src/components/Table/middlewares/selection.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ type UseSelectionProps<DataType extends TableData> = {
2828

2929
/** Callback that is fired when the selection of rows in the table changes */
3030
onRowSelectionChange?: (selectedRowKeys: string[]) => void;
31+
32+
/** Whether the selection mode should key off the real screen size instead of shouldUseNarrowLayout (for tables inside a narrow pane modal / RHP) */
33+
shouldEnableSelectionInNarrowPaneModal?: boolean;
3134
};
3235

3336
type SelectionMethods = {
@@ -58,8 +61,14 @@ export default function useSelection<DataType extends TableData>({
5861
selectedKeys,
5962
currentFilters,
6063
onRowSelectionChange,
64+
shouldEnableSelectionInNarrowPaneModal,
6165
}: UseSelectionProps<DataType>): UseSelectionResult<DataType> {
62-
const {shouldUseNarrowLayout} = useResponsiveLayout();
66+
// When a table opts into selection inside a narrow pane modal (RHP), the selection-mode auto-sync keys off the real
67+
// screen size (isSmallScreenWidth) so it behaves correctly there (shouldUseNarrowLayout is always true in an RHP).
68+
// Otherwise it keeps the original shouldUseNarrowLayout behavior, so central-pane tables are unaffected.
69+
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
70+
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
71+
const selectionUsesNarrowLayout = shouldEnableSelectionInNarrowPaneModal ? isSmallScreenWidth : shouldUseNarrowLayout;
6372
const isSelectionModeEnabled = useMobileSelectionMode();
6473
const lastSelectedRowKeyRef = useRef<string | null>(null);
6574
const lastSelectedRowIsSelectedRef = useRef<boolean>(false);
@@ -90,16 +99,16 @@ export default function useSelection<DataType extends TableData>({
9099

91100
// Sync the selection mode with the screen size & selection state
92101
useEffect(() => {
93-
const isMobileMissingSelectionMode = shouldUseNarrowLayout && !isSelectionModeEnabled && selectedKeys.length;
94-
const isDesktopWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !shouldUseNarrowLayout;
102+
const isMobileMissingSelectionMode = selectionUsesNarrowLayout && !isSelectionModeEnabled && selectedKeys.length;
103+
const isDesktopWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !selectionUsesNarrowLayout;
95104
const isSelectionModeEnabledWithoutSelectableKeys = isSelectionModeEnabled && !selectableKeys.length && !originalSelectableCount;
96105

97106
if (isMobileMissingSelectionMode) {
98107
turnOnMobileSelectionMode();
99108
} else if (isDesktopWithoutSelectableKeys || isSelectionModeEnabledWithoutSelectableKeys) {
100109
turnOffMobileSelectionMode();
101110
}
102-
}, [shouldUseNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]);
111+
}, [selectionUsesNarrowLayout, isSelectionModeEnabled, selectedKeys.length, originalSelectableCount, selectableKeys.length]);
103112

104113
// When selection mode is turned off, clear the list of selected keys, so that re-enabling selection mode doesn't retain rows
105114
const wasSelectionModeEnabled = usePrevious(isSelectionModeEnabled);

src/components/Table/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ type TableProps<DataType extends TableData, ColumnKey extends string = string, F
147147
/** Whether multi selection is enabled */
148148
selectionEnabled?: boolean;
149149

150+
/**
151+
* Whether the selection UX (checkboxes / long-press selection mode) should be driven by the real screen size
152+
* (isSmallScreenWidth) instead of shouldUseNarrowLayout. Set this for tables rendered inside a narrow pane modal
153+
* (RHP), where shouldUseNarrowLayout is always true and would otherwise suppress selection entirely. Defaults to
154+
* false so central-pane tables keep their existing behavior.
155+
*/
156+
shouldEnableSelectionInNarrowPaneModal?: boolean;
157+
150158
/** Column configuration defining what columns to display and how. */
151159
columns: Array<TableColumn<ColumnKey>>;
152160

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import Icon from '@components/Icon';
2+
import ReportActionAvatars from '@components/ReportActionAvatars';
3+
import Table from '@components/Table';
4+
import Text from '@components/Text';
5+
import TextWithTooltip from '@components/TextWithTooltip';
6+
7+
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
8+
import useLocalize from '@hooks/useLocalize';
9+
import useStyleUtils from '@hooks/useStyleUtils';
10+
import useTheme from '@hooks/useTheme';
11+
import useThemeStyles from '@hooks/useThemeStyles';
12+
13+
import variables from '@styles/variables';
14+
15+
import CONST from '@src/CONST';
16+
17+
import React from 'react';
18+
import {View} from 'react-native';
19+
20+
import type {ReportParticipantRowData} from '.';
21+
22+
type ReportParticipantsTableRowProps = {
23+
/** The participant item for the row */
24+
item: ReportParticipantRowData;
25+
26+
/** The index of the row relative to all other rows */
27+
rowIndex: number;
28+
29+
/** Whether to use narrow table row layout */
30+
shouldUseNarrowTableLayout: boolean;
31+
};
32+
33+
export default function ReportParticipantsTableRow({item, rowIndex, shouldUseNarrowTableLayout}: ReportParticipantsTableRowProps) {
34+
const theme = useTheme();
35+
const styles = useThemeStyles();
36+
const styleUtils = useStyleUtils();
37+
const {translate} = useLocalize();
38+
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
39+
40+
const avatarSize = shouldUseNarrowTableLayout ? CONST.AVATAR_SIZE.DEFAULT : CONST.AVATAR_SIZE.SMALL;
41+
// Only admins surface a role, matching the production Members list where non-admins have no role indicator.
42+
const roleLabel = item.isGroupChat && item.isAdmin ? translate('common.admin') : '';
43+
const accessibilityLabel = roleLabel ? `${item.name}, ${item.email}, ${roleLabel}` : `${item.name}, ${item.email}`;
44+
const memberSubtitle = shouldUseNarrowTableLayout && roleLabel ? `${roleLabel}${item.email}` : item.email;
45+
46+
const getSecondaryAvatarContainerStyle = (hovered: boolean) => [
47+
styleUtils.getBackgroundAndBorderStyle(theme.sidebar),
48+
hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined,
49+
];
50+
51+
return (
52+
<Table.Row
53+
interactive
54+
rowIndex={rowIndex}
55+
disabled={item.disabled}
56+
accessibilityLabel={accessibilityLabel}
57+
offlineWithFeedback={{pendingAction: item.pendingAction}}
58+
onPress={item.action}
59+
>
60+
{(hovered) => (
61+
<>
62+
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
63+
<ReportActionAvatars
64+
size={avatarSize}
65+
accountIDs={[item.accountID]}
66+
fallbackDisplayName={item.name ?? item.email}
67+
secondaryAvatarContainerStyle={getSecondaryAvatarContainerStyle(!!hovered)}
68+
/>
69+
<View style={[shouldUseNarrowTableLayout && styles.gap1, styles.flex1]}>
70+
<TextWithTooltip
71+
shouldShowTooltip
72+
text={item.name}
73+
style={[styles.optionDisplayName, styles.pre]}
74+
numberOfLines={1}
75+
/>
76+
<TextWithTooltip
77+
shouldShowTooltip
78+
text={memberSubtitle}
79+
style={[styles.textLabelSupporting, styles.lh16, styles.pre]}
80+
numberOfLines={1}
81+
/>
82+
</View>
83+
</View>
84+
85+
{!shouldUseNarrowTableLayout && item.isGroupChat && (
86+
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
87+
<Text numberOfLines={1}>{roleLabel}</Text>
88+
</View>
89+
)}
90+
91+
<Icon
92+
src={icons.ArrowRight}
93+
fill={theme.icon}
94+
additionalStyles={[styles.justifyContentCenter, styles.alignItemsCenter, !hovered && styles.opacitySemiTransparent]}
95+
width={variables.iconSizeNormal}
96+
height={variables.iconSizeNormal}
97+
/>
98+
</>
99+
)}
100+
</Table.Row>
101+
);
102+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type {CompareItemsCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table';
2+
import Table from '@components/Table';
3+
4+
import useLocalize from '@hooks/useLocalize';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
7+
import tokenizedSearch from '@libs/tokenizedSearch';
8+
9+
import variables from '@styles/variables';
10+
11+
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
12+
13+
import type {ListRenderItemInfo} from '@shopify/flash-list';
14+
15+
import React from 'react';
16+
import {View} from 'react-native';
17+
18+
import ReportParticipantsTableRow from './ReportParticipantsTableRow';
19+
20+
type ReportParticipantsTableColumnKey = 'member' | 'role' | 'actions';
21+
22+
type ReportParticipantRowData = TableData & {
23+
/** The accountID of the participant */
24+
accountID: number;
25+
26+
/** The participant's login (email/phone) */
27+
login: string;
28+
29+
/** The participant's display name */
30+
name: string;
31+
32+
/** The participant's formatted email/phone shown as the subtitle */
33+
email: string;
34+
35+
/** Whether the participant is an admin of the group chat */
36+
isAdmin: boolean;
37+
38+
/** Whether the participant list belongs to a group chat (drives the role column & selection) */
39+
isGroupChat: boolean;
40+
41+
/** The pending action for the row, used for offline feedback */
42+
pendingAction?: OnyxCommon.PendingAction;
43+
44+
/** Callback invoked when the row is pressed (outside of selection mode) */
45+
action: () => void;
46+
};
47+
48+
type ReportParticipantsTableProps = {
49+
ref?: React.Ref<TableHandle<ReportParticipantRowData, ReportParticipantsTableColumnKey, string>>;
50+
51+
/** The rows to render in the table */
52+
members: ReportParticipantRowData[];
53+
54+
/** Whether the participant list belongs to a group chat */
55+
isGroupChat: boolean;
56+
57+
/** Whether multi-selection is enabled */
58+
selectionEnabled: boolean;
59+
60+
/** The list of selected row keys */
61+
selectedKeys: string[];
62+
63+
/** Whether to show the find-member search bar */
64+
shouldShowSearchBar: boolean;
65+
66+
/** Callback when the set of selected rows changes */
67+
onRowSelectionChange: (selectedRowKeys: string[]) => void;
68+
};
69+
70+
export default function ReportParticipantsTable({ref, members, isGroupChat, selectionEnabled, selectedKeys, shouldShowSearchBar, onRowSelectionChange}: ReportParticipantsTableProps) {
71+
const styles = useThemeStyles();
72+
const {translate, localeCompare} = useLocalize();
73+
74+
const columns: Array<TableColumn<ReportParticipantsTableColumnKey>> = [
75+
{
76+
key: 'member',
77+
label: translate('common.member'),
78+
sortable: false,
79+
},
80+
...(isGroupChat
81+
? [
82+
{
83+
key: 'role' as const,
84+
label: translate('common.role'),
85+
sortable: false,
86+
width: variables.workspaceMembersRoleColumnWidth,
87+
},
88+
]
89+
: []),
90+
{
91+
key: 'actions',
92+
label: '',
93+
width: variables.tableCaretColumnWidth,
94+
sortable: false,
95+
},
96+
];
97+
98+
const compareItems: CompareItemsCallback<ReportParticipantRowData, ReportParticipantsTableColumnKey> = (item1, item2) =>
99+
localeCompare(item1.name.toLowerCase(), item2.name.toLowerCase());
100+
101+
const isItemInSearch: IsItemInSearchCallback<ReportParticipantRowData> = (item, searchValue) => {
102+
const results = tokenizedSearch([item], searchValue, (option) => [option.name, option.email, option.login]);
103+
return results.length > 0;
104+
};
105+
106+
const renderItem = ({item, index}: ListRenderItemInfo<ReportParticipantRowData>) => (
107+
<ReportParticipantsTableRow
108+
item={item}
109+
rowIndex={index}
110+
shouldUseNarrowTableLayout
111+
/>
112+
);
113+
114+
return (
115+
<Table
116+
ref={ref}
117+
data={members}
118+
columns={columns}
119+
selectedKeys={selectedKeys}
120+
selectionEnabled={selectionEnabled}
121+
shouldEnableSelectionInNarrowPaneModal
122+
initialSortColumn="member"
123+
title={translate('common.members')}
124+
renderItem={renderItem}
125+
compareItems={compareItems}
126+
isItemInSearch={isItemInSearch}
127+
keyExtractor={(item) => item.keyForList}
128+
onRowSelectionChange={onRowSelectionChange}
129+
>
130+
{shouldShowSearchBar && (
131+
<View style={[styles.mb3, styles.mh5]}>
132+
<Table.SearchBar
133+
label={translate('selectionList.findMember')}
134+
style={[styles.mb0, styles.mh0]}
135+
/>
136+
</View>
137+
)}
138+
<Table.Header />
139+
<Table.Body />
140+
</Table>
141+
);
142+
}
143+
144+
export type {ReportParticipantsTableColumnKey, ReportParticipantRowData};

0 commit comments

Comments
 (0)