-
Notifications
You must be signed in to change notification settings - Fork 4k
Migrate domain members list to the new Table component #94445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1212fc6
c8cff09
0bf5ffb
9a0bda4
c8d9ea8
a5c4c17
aae3003
ce8c04c
721c011
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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> | ||
| ); | ||
| } | ||
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
|
|
||
| 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}; | ||


There was a problem hiding this comment.
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
brickRoadIndicatoron each row, right?There was a problem hiding this comment.
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: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
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
Memberstable, which I would imagine would be the same as Categories? Curious for others' thoughts too.There was a problem hiding this comment.
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:
That would be ideal. It's consistent with Spend page too.