Skip to content
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8860,6 +8860,9 @@ const CONST = {
CREATE_GROUP_BUTTON: 'DomainGroups-CreateGroupButton',
ROW: 'DomainGroups-Row',
},
MEMBERS: {
ROW: 'DomainMembers-Row',
},
},
},

Expand Down
2 changes: 1 addition & 1 deletion src/components/Table/TableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function TableHeader<DataType extends TableData, ColumnKey extends string = stri
<View
style={[
styles.pv2,
styles.ph3,
shouldUseNarrowTableLayout && !isSelectionCheckboxVisible ? styles.ph4 : styles.ph3,
styles.mh5,
styles.highlightBG,
styles.borderBottom,
Expand Down
111 changes: 111 additions & 0 deletions src/components/Tables/DomainMembersTable/DomainMembersTableRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
import ReportActionAvatars from '@components/ReportActionAvatars';
import Table from '@components/Table';
import TextWithTooltip from '@components/TextWithTooltip';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {DomainMemberRowData} from '.';

type DomainMembersTableRowProps = {
/** Data about the domain member */
item: DomainMemberRowData;

/** The index of the row relative to all other rows */
rowIndex: number;

/** Whether to use narrow table row layout */
shouldUseNarrowTableLayout: boolean;

/** Whether the group column should be shown */
shouldShowGroupColumn: boolean;
};

export default function DomainMembersTableRow({item, rowIndex, shouldUseNarrowTableLayout, shouldShowGroupColumn}: DomainMembersTableRowProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're missing the brickRoadIndicator on each row, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I think we should keep the behavior consistent here.

On main, we currently have two ways to surface member errors:

  1. Inline error row — a full error message below the row (e.g., billing owner errors).
  2. Brick road dot — just a red dot on the row, with the actual error shown on the detail page (e.g., vacation delegate / 2FA exempt errors).

I think we should unify this and have all member errors use the table's inline error row, similar to Categories. That way, there's a single, consistent error pattern across the table instead of mixing inline errors and brick road indicators.

What do you think? Should we keep everything consistent with inline error rows, or do you think we should keep the brick road dot for detail-only errors?

cc: @Expensify/design

@dannymcclain dannymcclain Jun 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably go with however we do it on the main Members table, which I would imagine would be the same as Categories? Curious for others' thoughts too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with that. I think if we can consistently pull this off:

a full error message below the row

That would be ideal. It's consistent with Spend page too.

const theme = useTheme();
const styles = useThemeStyles();
const styleUtils = useStyleUtils();
const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']);

const avatarSize = shouldUseNarrowTableLayout ? CONST.AVATAR_SIZE.DEFAULT : CONST.AVATAR_SIZE.SMALL;
const shouldShowGroupInColumn = shouldShowGroupColumn && !shouldUseNarrowTableLayout;
let memberSubtitle = item.email;
if (shouldUseNarrowTableLayout && shouldShowGroupColumn) {
memberSubtitle = `${item.groupName} • ${item.email}`;
}
const accessibilityLabel = [item.name, shouldShowGroupColumn ? item.groupName : null, item.email].filter(Boolean).join(', ');

const getSecondaryAvatarContainerStyle = (hovered: boolean) => [
styleUtils.getBackgroundAndBorderStyle(theme.sidebar),
hovered ? styleUtils.getBackgroundAndBorderStyle(styles.sidebarLinkHover?.backgroundColor ?? theme.sidebar) : undefined,
];

return (
<Table.Row
interactive
rowIndex={rowIndex}
disabled={item.disabled}
accessibilityLabel={accessibilityLabel}
skeletonReasonAttributes={{context: 'domainMembersTableRow'}}
sentryLabel={CONST.SENTRY_LABEL.DOMAIN.MEMBERS.ROW}
offlineWithFeedback={{
errors: item.errors,
pendingAction: item.pendingAction,
onClose: item.dismissError,
}}
onPress={item.action}
>
{({hovered}) => (
<>
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}>
<ReportActionAvatars
size={avatarSize}
accountIDs={[item.accountID]}
fallbackDisplayName={item.name}
shouldShowTooltip
secondaryAvatarContainerStyle={getSecondaryAvatarContainerStyle(!!hovered)}
/>
<View style={[shouldUseNarrowTableLayout && styles.gap1, styles.flex1]}>
<TextWithTooltip
shouldShowTooltip
text={item.name}
style={[styles.optionDisplayName, styles.pre]}
numberOfLines={1}
/>
<TextWithTooltip
shouldShowTooltip
text={memberSubtitle}
style={[styles.textLabelSupporting, styles.lh16, styles.pre]}
numberOfLines={1}
/>
</View>
</View>

{shouldShowGroupInColumn && (
<View style={[styles.justifyContentCenter, styles.flex1]}>
<TextWithTooltip
shouldShowTooltip
numberOfLines={1}
text={item.groupName}
style={[styles.lh16, styles.optionDisplayName, styles.pre]}
/>
</View>
)}

<Icon
src={icons.ArrowRight}
fill={theme.icon}
additionalStyles={[styles.justifyContentCenter, styles.alignItemsCenter, (!hovered || item.disabled) && styles.opacitySemiTransparent]}
width={variables.iconSizeNormal}
height={variables.iconSizeNormal}
/>
</>
)}
</Table.Row>
);
}
174 changes: 174 additions & 0 deletions src/components/Tables/DomainMembersTable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type {ListRenderItemInfo} from '@shopify/flash-list';
import React, {useEffect} from 'react';
import {View} from 'react-native';
import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData} from '@components/Table';
import Table from '@components/Table';
import {useTableContext} from '@components/Table/TableContext';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import tokenizedSearch from '@libs/tokenizedSearch';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import DomainMembersTableRow from './DomainMembersTableRow';

type DomainMembersTableColumnKey = 'member' | 'group' | 'actions';
type DomainMembersTableFilterKey = 'group';

type DomainMemberRowData = TableData & {
accountID: number;
login: string;
name: string;
email: string;
groupName: string;
errors?: OnyxCommon.Errors;
pendingAction?: OnyxCommon.PendingAction;
action: () => void;
dismissError: () => void;
};

type DomainMembersTableProps = {
members: DomainMemberRowData[];
selectionEnabled: boolean;
selectedKeys: string[];
onRowSelectionChange: (selectedRowKeys: string[]) => void;
shouldShowGroupColumn: boolean;
filterConfig?: FilterConfig<DomainMembersTableFilterKey>;
isItemInFilter?: IsItemInFilterCallback<DomainMemberRowData>;
EmptyStateComponent: React.ReactElement;
};

const ALL_MEMBERS_VALUE = 'all';

/**
* Clears stale group filter values when the filter is hidden or the selected group disappears from Onyx.
*/
function DomainMembersGroupFilterSync({shouldShowGroupFilter, groupOptionValuesKey}: {shouldShowGroupFilter: boolean; groupOptionValuesKey: string}) {
const {activeFilters, tableMethods} = useTableContext<DomainMemberRowData, DomainMembersTableColumnKey>();
const groupFilterValue = activeFilters.group;

useEffect(() => {
const activeGroupFilter = typeof groupFilterValue === 'string' ? groupFilterValue : undefined;
const groupOptionValues = groupOptionValuesKey ? groupOptionValuesKey.split(',') : [];

if (!shouldShowGroupFilter) {
if (activeGroupFilter && activeGroupFilter !== ALL_MEMBERS_VALUE) {
tableMethods.updateFilter({key: 'group', value: ALL_MEMBERS_VALUE});
}
return;
}

if (activeGroupFilter && activeGroupFilter !== ALL_MEMBERS_VALUE && !groupOptionValues.includes(activeGroupFilter)) {
tableMethods.updateFilter({key: 'group', value: ALL_MEMBERS_VALUE});
}
}, [shouldShowGroupFilter, groupOptionValuesKey, groupFilterValue, tableMethods]);

return null;
}

export default function DomainMembersTable({
members,
selectionEnabled,
selectedKeys,
onRowSelectionChange,
shouldShowGroupColumn,
filterConfig,
isItemInFilter,
EmptyStateComponent,
}: DomainMembersTableProps) {
const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout();

const shouldUseNarrowTableLayout = shouldUseNarrowLayout || isMediumScreenWidth;
const shouldShowSearchBar = members.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
const shouldShowGroupFilter = !!filterConfig;
const groupOptionValuesKey = filterConfig?.group.options.map((option) => option.value).join(',') ?? '';
const isEmpty = members.length === 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The illustrated empty state is gone when a group filter matches no members

Image

production:

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also search input is now on next line. Not same line as group filter dropdown.
Is this approved design?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Expensify/design, should we show the illustration here, or should we match the other tables and simply display "No results found"?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @JS00001 I forget what we decided to do here... I think maybe we decided to do something like:

  • if table has truly no data, we show a generic empty state encouraging you to create something
  • if search input returns no data from your search, we use that illustrated empty state above?


const domainMembersTableColumns: Array<TableColumn<DomainMembersTableColumnKey>> = [
{
key: 'member',
label: translate('domain.members.title'),
sortable: true,
},
...(shouldShowGroupColumn
? [
{
key: 'group' as const,
label: translate('common.group'),
sortable: true,
},
]
: []),
{
key: 'actions',
label: '',
sortable: false,
width: variables.tableCaretColumnWidth,
},
];

const compareTableItems: CompareItemsCallback<DomainMemberRowData> = (item1, item2, activeSorting) => {
const orderMultiplier = activeSorting.order === 'asc' ? 1 : -1;

if (activeSorting.columnKey === 'group') {
return localeCompare(item1.groupName, item2.groupName) * orderMultiplier;
}

return localeCompare(item1.name, item2.name) * orderMultiplier;
};

const isTableItemInSearch: IsItemInSearchCallback<DomainMemberRowData> = (item, searchValue) => {
const results = tokenizedSearch([item], searchValue, (option) => [option.name, option.email]);
return results.length > 0;
};

const renderTableItem = ({item, index}: ListRenderItemInfo<DomainMemberRowData>) => (
<DomainMembersTableRow
item={item}
rowIndex={index}
shouldUseNarrowTableLayout={shouldUseNarrowTableLayout}
shouldShowGroupColumn={shouldShowGroupColumn}
/>
);

return (
<Table
data={members}
columns={domainMembersTableColumns}
renderItem={renderTableItem}
compareItems={compareTableItems}
isItemInSearch={isTableItemInSearch}
initialSortColumn="member"
title={translate('domain.members.title')}
keyExtractor={(item) => item.keyForList}
selectionEnabled={selectionEnabled}
selectedKeys={selectedKeys}
onRowSelectionChange={onRowSelectionChange}
filters={filterConfig}
isItemInFilter={isItemInFilter}
>
{isEmpty && EmptyStateComponent}
{!isEmpty && (
<>
<DomainMembersGroupFilterSync
shouldShowGroupFilter={shouldShowGroupFilter}
groupOptionValuesKey={groupOptionValuesKey}
/>
{shouldShowGroupFilter && (
<View style={[styles.mh5, styles.mb3]}>
<Table.FilterButtons />
</View>
)}
{shouldShowSearchBar && <Table.SearchBar label={translate('domain.members.findMember')} />}
<Table.Header />
<Table.Body />
</>
)}
</Table>
);
}

export type {DomainMemberRowData, DomainMembersTableColumnKey, DomainMembersTableFilterKey};
Loading
Loading