From 717e22b98c9642804dfaf9333fc23b6b0d23df17 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Thu, 22 Jun 2023 16:56:08 +0100 Subject: [PATCH 001/592] chore: wip --- .../BaseSelectionListRadio.js | 29 +++- .../SelectionListRadio/CheckboxListItem.js | 96 +++++++++++ .../selectionListRadioPropTypes.js | 4 + src/pages/workspace/WorkspaceMembersPage.js | 161 +++++++++++++----- 4 files changed, 241 insertions(+), 49 deletions(-) create mode 100644 src/components/SelectionListRadio/CheckboxListItem.js diff --git a/src/components/SelectionListRadio/BaseSelectionListRadio.js b/src/components/SelectionListRadio/BaseSelectionListRadio.js index 2b8f373b987a..ac425771dd96 100644 --- a/src/components/SelectionListRadio/BaseSelectionListRadio.js +++ b/src/components/SelectionListRadio/BaseSelectionListRadio.js @@ -11,6 +11,7 @@ import CONST from '../../CONST'; import variables from '../../styles/variables'; import {propTypes as selectionListRadioPropTypes, defaultProps as selectionListRadioDefaultProps} from './selectionListRadioPropTypes'; import RadioListItem from './RadioListItem'; +import CheckboxListItem from './CheckboxListItem'; import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; import SafeAreaConsumer from '../SafeAreaConsumer'; import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState'; @@ -44,6 +45,8 @@ function BaseSelectionListRadio(props) { let offset = 0; const itemLayouts = [{length: 0, offset}]; + let selectedCount = 0; + _.each(props.sections, (section, sectionIndex) => { // We're not rendering any section header, but we need to push to the array // because React Native accounts for it in getItemLayout @@ -51,16 +54,16 @@ function BaseSelectionListRadio(props) { itemLayouts.push({length: sectionHeaderHeight, offset}); offset += sectionHeaderHeight; - _.each(section.data, (option, optionIndex) => { + _.each(section.data, (item, optionIndex) => { // Add item to the general flattened array allOptions.push({ - ...option, + ...item, sectionIndex, index: optionIndex, }); // If disabled, add to the disabled indexes array - if (section.isDisabled || option.isDisabled) { + if (section.isDisabled || item.isDisabled) { disabledOptionsIndexes.push(disabledIndex); } disabledIndex += 1; @@ -69,6 +72,10 @@ function BaseSelectionListRadio(props) { const fullItemHeight = variables.optionRowHeight; itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; + + if (item.isSelected) { + selectedCount++; + } }); // We're not rendering any section footer, but we need to push to the array @@ -80,6 +87,12 @@ function BaseSelectionListRadio(props) { // because React Native accounts for it in getItemLayout itemLayouts.push({length: 0, offset}); + if (selectedCount > 1 && !props.canSelectMultiple) { + throw new Error( + 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.', + ); + } + return { allOptions, disabledOptionsIndexes, @@ -159,6 +172,16 @@ function BaseSelectionListRadio(props) { const renderItem = ({item, index, section}) => { const isFocused = focusedIndex === index + lodashGet(section, 'indexOffset', 0); + if (props.canSelectMultiple) { + return ( + + ); + } + return ( {}, +}; + +function CheckboxListItem(props) { + // TODO: REVIEW ERRORS + + const errors = {}; + + return ( + <> + props.onSelectRow(props.item)} + accessibilityLabel={props.item.text} + accessibilityRole="checkbox" + accessibilityState={{checked: props.item.isSelected}} + hoverDimmingValue={1} + hoverStyle={styles.hoveredComponentBG} + focusStyle={styles.hoveredComponentBG} + > + props.onSelectRow(props.item)} + /> + + + + + {props.item.text} + + + {Boolean(props.item.alternateText) && ( + + {props.item.alternateText} + + )} + + + + {props.item.isAdmin && ( + + {props.translate('common.admin')} + + )} + + {!_.isEmpty(errors[item.accountID]) && ( + + )} + + ); +} + +CheckboxListItem.displayName = 'CheckboxListItem'; +CheckboxListItem.propTypes = propTypes; +CheckboxListItem.defaultProps = defaultProps; + +export default withLocalize(CheckboxListItem); diff --git a/src/components/SelectionListRadio/selectionListRadioPropTypes.js b/src/components/SelectionListRadio/selectionListRadioPropTypes.js index 14e41b195d7b..39081def712b 100644 --- a/src/components/SelectionListRadio/selectionListRadioPropTypes.js +++ b/src/components/SelectionListRadio/selectionListRadioPropTypes.js @@ -33,6 +33,9 @@ const propTypes = { }), ).isRequired, + /** Whether this is a multi-select list */ + canSelectMultiple: PropTypes.bool, + /** Callback to fire when a row is tapped */ onSelectRow: PropTypes.func, @@ -71,6 +74,7 @@ const propTypes = { }; const defaultProps = { + canSelectMultiple: false, onSelectRow: () => {}, textInputLabel: '', textInputPlaceholder: '', diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 2a5cecb1cf94..abf29731ca6d 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -38,6 +38,7 @@ import PressableWithFeedback from '../../components/Pressable/PressableWithFeedb import usePrevious from '../../hooks/usePrevious'; import Log from '../../libs/Log'; import * as PersonalDetailsUtils from '../../libs/PersonalDetailsUtils'; +import SelectionListRadio from '../../components/SelectionListRadio'; const propTypes = { /** The personal details of the person who is logged in */ @@ -249,9 +250,9 @@ function WorkspaceMembersPage(props) { // Add or remove the user if the checkbox is enabled if (_.contains(selectedEmployees, Number(accountID))) { - removeUser(accountID); + removeUser(Number(accountID)); } else { - addUser(accountID); + addUser(Number(accountID)); } }, [selectedEmployees, addUser, removeUser], @@ -395,6 +396,77 @@ function WorkspaceMembersPage(props) { const policyID = lodashGet(props.route, 'params.policyID'); const policyName = lodashGet(props.policy, 'name'); + const getListData = () => { + let result = []; + + _.each(props.policyMembers, (policyMember, accountID) => { + if (isDeletedPolicyMember(policyMember)) { + return; + } + + const details = props.personalDetails[accountID]; + + if (!details) { + Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); + return; + } + + // If search value is provided, filter out members that don't match the search value + if (searchValue.trim()) { + let memberDetails = ''; + if (details.login) { + memberDetails += ` ${details.login.toLowerCase()}`; + } + if (details.firstName) { + memberDetails += ` ${details.firstName.toLowerCase()}`; + } + if (details.lastName) { + memberDetails += ` ${details.lastName.toLowerCase()}`; + } + if (details.displayName) { + memberDetails += ` ${details.displayName.toLowerCase()}`; + } + if (details.phoneNumber) { + memberDetails += ` ${details.phoneNumber.toLowerCase()}`; + } + + if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) { + return; + } + } + + // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails + // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they + // see random people added to their policy, but guides having access to the policies help set them up. + if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) { + if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) { + return; + } + } + + result.push({ + keyForList: accountID, + isSelected: _.contains(selectedEmployees, Number(accountID)), + text: props.formatPhoneNumber(details.displayName), + alternateText: props.formatPhoneNumber(details.login), + isAdmin: props.session.email === details.login || policyMember.role === 'admin', + avatar: { + source: UserUtils.getAvatar(details.avatar, accountID), + name: details.login, + type: CONST.ICON_TYPE_AVATAR, + }, + }); + }); + + result = _.sortBy(result, (value) => value.text.toLowerCase()); + + return result; + }; + + const data2 = getListData(); + + const headerMessage = searchValue.trim() && !data2.length ? props.translate('common.noResultsFound') : ''; + return ( - - */} + {/* */} + {/* */} + {/* {data.length > 0 ? ( */} + + + _.contains(selectedEmployees, Number(accountID)))} + onPress={() => toggleAllUsers(removableMembers)} + /> + + {props.translate('workspace.people.selectAll')} + + + + toggleUser(item.keyForList)} /> + + {/* item.login} */} + {/* showsVerticalScrollIndicator */} + {/* style={[styles.ph5, styles.pb5]} */} + {/* contentContainerStyle={safeAreaPaddingBottomStyle} */} + {/* keyboardShouldPersistTaps="handled" */} + {/* /> */} - {data.length > 0 ? ( - - - toggleAllUsers(removableMembers)} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX} - accessibilityState={{ - checked: !_.isEmpty(removableMembers) && _.every(_.keys(removableMembers), (accountID) => _.contains(selectedEmployees, Number(accountID))), - }} - accessibilityLabel={props.translate('workspace.people.selectAll')} - hoverDimmingValue={1} - pressDimmingValue={0.7} - > - _.contains(selectedEmployees, Number(accountID)))} - onPress={() => toggleAllUsers(removableMembers)} - accessibilityLabel={props.translate('workspace.people.selectAll')} - /> - - - {props.translate('workspace.people.selectAll')} - - - item.login} - showsVerticalScrollIndicator - style={[styles.ph5, styles.pb5]} - contentContainerStyle={safeAreaPaddingBottomStyle} - keyboardShouldPersistTaps="handled" - /> - - ) : ( - - {props.translate('workspace.common.memberNotFound')} - - )} + {/* ) : ( */} + {/* */} + {/* {props.translate('workspace.common.memberNotFound')} */} + {/* */} + {/* )} */} )} From ae1586483be6e4088bbde35fc554407b6e70ca42 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Tue, 27 Jun 2023 17:12:21 +0100 Subject: [PATCH 002/592] chore: fix conflicts --- .../BaseSelectionListRadio.js | 21 ++++++++++ .../SelectionListRadio/CheckboxListItem.js | 7 +++- .../selectionListRadioPropTypes.js | 6 ++- src/pages/workspace/WorkspaceMembersPage.js | 39 ++++++++++++------- src/styles/styles.js | 3 +- 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/src/components/SelectionListRadio/BaseSelectionListRadio.js b/src/components/SelectionListRadio/BaseSelectionListRadio.js index ac425771dd96..f7183be08149 100644 --- a/src/components/SelectionListRadio/BaseSelectionListRadio.js +++ b/src/components/SelectionListRadio/BaseSelectionListRadio.js @@ -15,6 +15,9 @@ import CheckboxListItem from './CheckboxListItem'; import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; import SafeAreaConsumer from '../SafeAreaConsumer'; import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState'; +import Checkbox from '../Checkbox'; +import withLocalize from '../withLocalize'; +import PressableWithFeedback from '../Pressable/PressableWithFeedback'; const propTypes = { ...keyboardStatePropTypes, @@ -97,6 +100,7 @@ function BaseSelectionListRadio(props) { allOptions, disabledOptionsIndexes, itemLayouts, + allSelected: selectedCount === allOptions.length - disabledOptionsIndexes.length, }; }; @@ -258,6 +262,23 @@ function BaseSelectionListRadio(props) { {props.headerMessage} )} + {!props.headerMessage && props.canSelectMultiple && ( + + + + {props.translate('workspace.people.selectAll')} + + + )} props.onSelectRow(props.item)} + disabled={props.item.isDisabled} + disabledStyle={styles.buttonOpacityDisabled} accessibilityLabel={props.item.text} accessibilityRole="checkbox" accessibilityState={{checked: props.item.isSelected}} @@ -49,11 +51,12 @@ function CheckboxListItem(props) { focusStyle={styles.hoveredComponentBG} > props.onSelectRow(props.item)} /> - + {}, + onSelectAll: () => {}, textInputLabel: '', textInputPlaceholder: '', textInputValue: '', diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index abf29731ca6d..1c3a5370baa9 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -30,8 +30,6 @@ import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoun import networkPropTypes from '../../components/networkPropTypes'; import * as UserUtils from '../../libs/UserUtils'; import FormHelpMessage from '../../components/FormHelpMessage'; -import TextInput from '../../components/TextInput'; -import KeyboardDismissingFlatList from '../../components/KeyboardDismissingFlatList'; import withCurrentUserPersonalDetails from '../../components/withCurrentUserPersonalDetails'; import * as PolicyUtils from '../../libs/PolicyUtils'; import PressableWithFeedback from '../../components/Pressable/PressableWithFeedback'; @@ -204,8 +202,16 @@ function WorkspaceMembersPage(props) { * @param {Object} memberList */ const toggleAllUsers = (memberList) => { - const accountIDList = _.map(_.keys(memberList), (memberAccountID) => Number(memberAccountID)); - setSelectedEmployees((prevSelected) => (!_.every(accountIDList, (memberAccountID) => _.contains(prevSelected, memberAccountID)) ? accountIDList : [])); + const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled); + const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, Number(member.keyForList))); + + if (everyoneSelected) { + setSelectedEmployees([]); + } else { + const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList)); + setSelectedEmployees(everyAccountId); + } + validateSelection(); }; @@ -447,6 +453,7 @@ function WorkspaceMembersPage(props) { result.push({ keyForList: accountID, isSelected: _.contains(selectedEmployees, Number(accountID)), + isDisabled: props.session.email === details.login, text: props.formatPhoneNumber(details.displayName), alternateText: props.formatPhoneNumber(details.login), isAdmin: props.session.email === details.login || policyMember.role === 'admin', @@ -523,15 +530,15 @@ function WorkspaceMembersPage(props) { {/* */} {/* {data.length > 0 ? ( */} - - _.contains(selectedEmployees, Number(accountID)))} - onPress={() => toggleAllUsers(removableMembers)} - /> - - {props.translate('workspace.people.selectAll')} - - + {/* */} + {/* _.contains(selectedEmployees, Number(accountID)))} */} + {/* onPress={() => toggleAllUsers(removableMembers)} */} + {/* /> */} + {/* */} + {/* {props.translate('workspace.people.selectAll')} */} + {/* */} + {/* */} toggleUser(item.keyForList)} + onSelectAll={() => toggleAllUsers(data2)} + initiallyFocusedOptionKey={lodashGet( + _.find(data2, (item) => !item.isDisabled), + 'keyForList', + undefined, + )} /> {/* Date: Tue, 27 Jun 2023 08:47:55 +0100 Subject: [PATCH 003/592] chore: add avatar to checkbox item --- .../SelectionListRadio/CheckboxListItem.js | 42 ++-- src/pages/workspace/WorkspaceMembersPage.js | 233 ++++++------------ src/styles/utilities/spacing.js | 4 + 3 files changed, 96 insertions(+), 183 deletions(-) diff --git a/src/components/SelectionListRadio/CheckboxListItem.js b/src/components/SelectionListRadio/CheckboxListItem.js index 72a52bb9f349..fa193b4cc9ec 100644 --- a/src/components/SelectionListRadio/CheckboxListItem.js +++ b/src/components/SelectionListRadio/CheckboxListItem.js @@ -10,6 +10,7 @@ import Checkbox from '../Checkbox'; import FormHelpMessage from '../FormHelpMessage'; import {propTypes as item} from '../UserDetailsTooltip/userDetailsTooltipPropTypes'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Avatar from '../Avatar'; const propTypes = { /** The section list item */ @@ -55,26 +56,27 @@ function CheckboxListItem(props) { isChecked={props.item.isSelected} onPress={() => props.onSelectRow(props.item)} /> - - - - - {props.item.text} - - - {Boolean(props.item.alternateText) && ( - - {props.item.alternateText} - - )} - - + + + + {props.item.text} + + {Boolean(props.item.alternateText) && ( + + {props.item.alternateText} + + )} {props.item.isAdmin && ( diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 1c3a5370baa9..176b720c3057 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -128,41 +128,6 @@ function WorkspaceMembersPage(props) { getWorkspaceMembers(); }, [props.network.isOffline, prevIsOffline, getWorkspaceMembers]); - /** - * This function will iterate through the details of each policy member to check if the - * search string matches with any detail and return that filter. - * @param {Array} policyMembersPersonalDetails - This is the list of policy members - * @param {*} search - This is the string that the user has entered - * @returns {Array} - The list of policy members that have anything similar to the searchValue - */ - const getMemberOptions = (policyMembersPersonalDetails, search) => { - // If no search value, we return all members. - if (_.isEmpty(search)) { - return policyMembersPersonalDetails; - } - - // We will filter through each policy member details to determine if they should be shown - return _.filter(policyMembersPersonalDetails, (member) => { - let memberDetails = ''; - if (member.login) { - memberDetails += ` ${member.login.toLowerCase()}`; - } - if (member.firstName) { - memberDetails += ` ${member.firstName.toLowerCase()}`; - } - if (member.lastName) { - memberDetails += ` ${member.lastName.toLowerCase()}`; - } - if (member.displayName) { - memberDetails += ` ${member.displayName.toLowerCase()}`; - } - if (member.phoneNumber) { - memberDetails += ` ${member.phoneNumber.toLowerCase()}`; - } - return OptionsListUtils.isSearchStringMatch(search, memberDetails); - }); - }; - /** * Open the modal to invite a user */ @@ -367,38 +332,15 @@ function WorkspaceMembersPage(props) { const policyOwner = lodashGet(props.policy, 'owner'); const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login'); - const removableMembers = {}; - let data = []; - _.each(props.policyMembers, (policyMember, accountID) => { - if (isDeletedPolicyMember(policyMember)) { - return; - } - const details = props.personalDetails[accountID]; - if (!details) { - Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); - return; - } - data.push({ - ...policyMember, - ...details, - }); - }); - data = _.sortBy(data, (value) => value.displayName.toLowerCase()); - data = getMemberOptions(data, searchValue.trim().toLowerCase()); - - // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails - // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they - // see random people added to their policy, but guides having access to the policies help set them up. - if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) { - data = _.reject(data, (member) => PolicyUtils.isExpensifyTeam(member.login || member.displayName)); - } - - _.each(data, (member) => { - if (member.accountID === props.session.accountID || member.login === props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - removableMembers[member.accountID] = member; - }); + + // const removableMembers = {}; + // _.each(data, (member) => { + // if (member.accountID === props.session.accountID || member.login === props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + // return; + // } + // removableMembers[member.accountID] = member; + // }); + const policyID = lodashGet(props.route, 'params.policyID'); const policyName = lodashGet(props.policy, 'name'); @@ -470,110 +412,75 @@ function WorkspaceMembersPage(props) { return result; }; - const data2 = getListData(); + const data = getListData(); - const headerMessage = searchValue.trim() && !data2.length ? props.translate('common.noResultsFound') : ''; + const headerMessage = searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : ''; return ( - {({safeAreaPaddingBottomStyle}) => ( - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - > - { - setSearchValue(''); - Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID)); - }} - shouldShowGetAssistanceButton - guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} - /> - setRemoveMembersConfirmModalVisible(false)} - prompt={props.translate('workspace.people.removeMembersPrompt')} - confirmText={props.translate('common.remove')} - cancelText={props.translate('common.cancel')} - /> - - -