diff --git a/src/ROUTES.js b/src/ROUTES.js index 8af98287e5d8..e27b24cc61b7 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -49,6 +49,8 @@ export default { VALIDATE_LOGIN_WITH_VALIDATE_CODE: 'v/:accountID/:validateCode', ENABLE_PAYMENTS: 'enable-payments', WORKSPACE_NEW: 'workspace/new', + WORKSPACE_INVITE: 'workspace/:policyID/invite', + getWorkspaceInviteRoute: policyID => `workspace/${policyID}/invite`, /** * @param {String} route diff --git a/src/languages/en.js b/src/languages/en.js index fea831a19833..a6667626d63b 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -35,6 +35,7 @@ export default { saveAndContinue: 'Save & Continue', settings: 'Settings', termsOfService: 'Terms of Service', + invite: 'Invite', }, attachmentPicker: { cameraPermissionRequired: 'Camera Permission Required', @@ -347,5 +348,14 @@ export default { uploadPhoto: 'Upload Photo', requestCall: 'Request a call', }, + invite: { + invitePeople: 'Invite People', + invitePeoplePrompt: 'Invite a colleague to your workspace.', + personalMessagePrompt: 'Add a Personal Message (Optional)', + enterEmailOrPhone: 'Email or Phone', + pleaseEnterValidLogin: 'Please ensure the email or phone number is valid (e.g. +15005550006).', + genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.', + welcomeNote: ({workspaceName}) => `You have been invited to the ${workspaceName} Workspace! Download the Expensify mobile App to start tracking your expenses.`, + }, }, }; diff --git a/src/languages/es.js b/src/languages/es.js index 00e53087b9d2..2aae71d0c220 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -33,6 +33,7 @@ export default { saveAndContinue: 'Guardar y Continuar', settings: 'Configuración', termsOfService: 'Términos de servicio', + invite: 'Invitar', }, attachmentPicker: { cameraPermissionRequired: 'Se necesita permiso para usar la cámara', @@ -301,5 +302,14 @@ export default { editPhoto: 'Editar Foto', requestCall: 'Concertar una llamada', }, + invite: { + invitePeople: 'Invitar a la gente', + invitePeoplePrompt: 'Invita a un colega a tu espacio de trabajo.', + personalMessagePrompt: 'Agregar un mensaje personal (Opcional)', + enterEmailOrPhone: 'Email o teléfono', + pleaseEnterValidLogin: 'Asegúrese de que el correo electrónico o el número de teléfono sean válidos (e.g. +15005550006).', + genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..', + welcomeNote: ({workspaceName}) => `¡Has sido invitado a la ${workspaceName} Espacio de trabajo! Descargue la aplicación móvil Expensify para comenzar a rastrear sus gastos.`, + }, }, }; diff --git a/src/libs/API.js b/src/libs/API.js index b5a76e0acd67..4a7e93120196 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -406,6 +406,17 @@ function GetIOUReport(parameters) { return Network.post(commandName, parameters); } +/** + * @returns {Promise} + */ +function GetPolicyList() { + const commandName = 'Get'; + const parameters = { + returnValueList: 'policyList', + }; + return Network.post(commandName, parameters); +} + /** * @returns {Promise} */ @@ -798,6 +809,21 @@ function BankAccount_Get(parameters) { return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST, true); } +/** + * @param {Object} parameters + * @param {Object[]} parameters.employees + * @param {String} parameters.welcomeNote + * @param {String} parameters.policyID + * @returns {Promise} + */ +function Policy_Employees_Merge(parameters) { + const commandName = 'Policy_Employees_Merge'; + requireParameters(['employees', 'welcomeNote', 'policyID'], parameters, commandName); + + // Always include returnPersonalDetails to ensure we get the employee's personal details in the response + return Network.post(commandName, {...parameters, returnPersonalDetails: true}); +} + /** * @param {Object} parameters * @param {String} parameters.accountNumber @@ -915,6 +941,7 @@ export { Get, GetAccountStatus, GetIOUReport, + GetPolicyList, GetPolicySummaryList, GetRequestCountryCode, Graphite_Timer, @@ -924,6 +951,7 @@ export { PersonalDetails_GetForEmails, PersonalDetails_Update, Plaid_GetLinkToken, + Policy_Employees_Merge, Push_Authenticate, RejectTransaction, Report_AddComment, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index f3cd8f074df1..dd51541077d5 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -27,7 +27,7 @@ import Navigation from '../Navigation'; import * as User from '../../actions/User'; import {setModalVisibility} from '../../actions/Modal'; import NameValuePair from '../../actions/NameValuePair'; -import {getPolicySummaries} from '../../actions/Policy'; +import {getPolicySummaries, getPolicyList} from '../../actions/Policy'; import modalCardStyleInterpolator from './modalCardStyleInterpolator'; import createCustomModalStackNavigator from './createCustomModalStackNavigator'; @@ -52,6 +52,7 @@ import { AddPersonalBankAccountModalStackNavigator, ReimbursementAccountModalStackNavigator, NewWorkspaceStackNavigator, + WorkspaceInviteModalStackNavigator, } from './ModalStackNavigators'; import SCREENS from '../../../SCREENS'; import Timers from '../../Timers'; @@ -128,6 +129,7 @@ class AuthScreens extends React.Component { fetchCountryCodeByRequestIP(); UnreadIndicatorUpdater.listenForReportChanges(); getPolicySummaries(); + getPolicyList(); // Refresh the personal details, timezone and betas every 30 minutes // There is no pusher event that sends updated personal details data yet @@ -290,6 +292,12 @@ class AuthScreens extends React.Component { component={ReimbursementAccountModalStackNavigator} listeners={modalScreenListeners} /> + ); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 90c941ec2f1f..2c48f3785e07 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -21,6 +21,7 @@ import IOUCurrencySelection from '../../../pages/iou/IOUCurrencySelection'; import ReportParticipantsPage from '../../../pages/ReportParticipantsPage'; import EnablePaymentsPage from '../../../pages/EnablePayments'; import AddPersonalBankAccountPage from '../../../pages/AddPersonalBankAccountPage'; +import WorkspaceInvitePage from '../../../pages/workspace/WorkspaceInvitePage'; import ReimbursementAccountPage from '../../../pages/ReimbursementAccount/ReimbursementAccountPage'; import NewWorkspacePage from '../../../pages/workspace/NewWorkspacePage'; @@ -164,6 +165,11 @@ const NewWorkspaceStackNavigator = createModalStackNavigator([{ name: 'NewWorkspace_Root', }]); +const WorkspaceInviteModalStackNavigator = createModalStackNavigator([{ + Component: WorkspaceInvitePage, + name: 'WorkspaceInvite_Root', +}]); + export { IOUBillStackNavigator, IOURequestModalStackNavigator, @@ -178,4 +184,5 @@ export { AddPersonalBankAccountModalStackNavigator, ReimbursementAccountModalStackNavigator, NewWorkspaceStackNavigator, + WorkspaceInviteModalStackNavigator, }; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index e18b0b75ae1c..03533590b820 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -119,6 +119,11 @@ export default { ReimbursementAccount_Root: ROUTES.ADD_VERIFIED_BANK_ACCOUNT, }, }, + WorkspaceInvite: { + screens: { + WorkspaceInvite_Root: ROUTES.WORKSPACE_INVITE, + }, + }, NewWorkspace: { screens: { NewWorkspace_Root: ROUTES.WORKSPACE_NEW, diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index fff00ea6a2d7..fac49bf4fd60 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -303,6 +303,7 @@ NetworkConnection.onReconnect(fetchPersonalDetails); export { fetchPersonalDetails, + formatPersonalDetails, getFromReportParticipants, getDisplayName, getDefaultAvatar, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index af04011c3c94..15867a448c5f 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,7 +1,31 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; -import {GetPolicySummaryList} from '../API'; +import {GetPolicySummaryList, GetPolicyList, Policy_Employees_Merge} from '../API'; import ONYXKEYS from '../../ONYXKEYS'; +import {formatPersonalDetails} from './PersonalDetails'; +import Growl from '../Growl'; +import CONST from '../../CONST'; +import {translate} from '../translate'; + +const allPolicies = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (val && key) { + allPolicies[key] = {...allPolicies[key], ...val}; + } + }, +}); + +let translateLocal = (phrase, variables) => translate(CONST.DEFAULT_LOCALE, phrase, variables); +Onyx.connect({ + key: ONYXKEYS.PREFERRED_LOCALE, + callback: (preferredLocale) => { + if (preferredLocale) { + translateLocal = (phrase, variables) => translate(preferredLocale, phrase, variables); + } + }, +}); /** * Takes a full policy summary that is returned from the policySummaryList and simplifies it so we are only storing @@ -17,6 +41,27 @@ function getSimplifiedPolicyObject(fullPolicy) { }; } +/** + * Simplifies the policyList response into an object containing an array of emails + * + * @param {Object} fullPolicy + * @returns {Object} + */ +function getSimplifiedEmployeeListObject(fullPolicy) { + const employeeListEmails = _.chain(fullPolicy.value.employeeList) + .pluck('email') + .flatten() + .unique() + .value(); + + return { + employeeList: employeeListEmails, + }; +} + +/** + * Fetches the policySummaryList from the API and saves a simplified version in Onyx + */ function getPolicySummaries() { GetPolicySummaryList() .then((data) => { @@ -30,7 +75,69 @@ function getPolicySummaries() { }); } +/** + * Fetches the policyList from the API and saves a simplified version in Onyx + */ +function getPolicyList() { + GetPolicyList() + .then((data) => { + if (data.jsonCode === 200) { + const policyDataToStore = _.reduce(data.policyList, (memo, policy) => ({ + ...memo, + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: getSimplifiedEmployeeListObject(policy), + }), {}); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.POLICY, policyDataToStore); + } + }); +} + +/** + * Merges the passed in login into the specified policy + * + * @param {String} login + * @param {String} welcomeNote + * @param {String} policyID + */ +function invite(login, welcomeNote, policyID) { + const key = `${ONYXKEYS.COLLECTION.POLICY}${policyID}`; + + // Make a shallow copy to preserve original data, and concat the login + const policy = _.clone(allPolicies[key]); + policy.employeeList = [...policy.employeeList, login]; + + // Optimistically add the user to the policy + Onyx.set(key, policy); + + // Make the API call to merge the login into the policy + Policy_Employees_Merge({ + employees: JSON.stringify([{email: login}]), + welcomeNote, + policyID, + }) + .then((data) => { + // Save the personalDetails for the invited user in Onyx + if (data.jsonCode === 200) { + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS, formatPersonalDetails(data.personalDetails)); + return; + } + + // If the operation failed, undo the optimistic addition + const policyDataWithoutLogin = _.clone(allPolicies[key]); + policyDataWithoutLogin.employeeList = _.without(allPolicies[key].employeeList, login); + Onyx.set(key, policyDataWithoutLogin); + + // Show the user feedback that the addition failed + let errorMessage = translateLocal('workspace.invite.genericFailureMessage'); + if (data.jsonCode === 402) { + errorMessage += ` ${translateLocal('workspace.invite.pleaseEnterValidLogin')}`; + } + + Growl.show(errorMessage, CONST.GROWL.ERROR, 5000); + }); +} + export { - // eslint-disable-next-line import/prefer-default-export getPolicySummaries, + getPolicyList, + invite, }; diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js new file mode 100644 index 000000000000..7637ee9947ea --- /dev/null +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {TextInput, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import Str from 'expensify-common/lib/str'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import styles from '../../styles/styles'; +import Text from '../../components/Text'; +import Button from '../../components/Button'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; +import {invite} from '../../libs/actions/Policy'; +import TextLink from '../../components/TextLink'; +import getEmailKeyboardType from '../../libs/getEmailKeyboardType'; +import themeColors from '../../styles/themes/default'; +import Growl from '../../libs/Growl'; +import CONST from '../../CONST'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The policy passed via the route */ + policy: PropTypes.shape({ + /** The policy name */ + name: PropTypes.string, + }), + + /** URL Route params */ + route: PropTypes.shape({ + /** Params from the URL path */ + params: PropTypes.shape({ + /** policyID passed via route: /workspace/:policyID/invite */ + policyID: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + policy: { + name: '', + }, +}; + +class WorkspaceInvitePage extends React.Component { + constructor(props) { + super(props); + + this.state = { + emailOrPhone: '', + welcomeNote: '', + }; + + this.inviteUser = this.inviteUser.bind(this); + } + + /** + * Gets the welcome note default text + * + * @returns {Object} + */ + getWelcomeNotePlaceholder() { + return this.props.translate('workspace.invite.welcomeNote', { + workspaceName: this.props.policy.name, + }); + } + + /** + * Handle the invite button click + */ + inviteUser() { + if (!Str.isValidEmail(this.state.emailOrPhone) && !Str.isValidPhone(this.state.emailOrPhone)) { + Growl.show(this.props.translate('workspace.invite.pleaseEnterValidLogin'), CONST.GROWL.ERROR, 5000); + return; + } + + invite(this.state.emailOrPhone, this.state.welcomeNote || this.getWelcomeNotePlaceholder(), + this.props.route.params.policyID); + Navigation.goBack(); + } + + render() { + return ( + + + + + + {this.props.translate('workspace.invite.invitePeoplePrompt')} + + + + {this.props.translate('workspace.invite.enterEmailOrPhone')} + + this.setState({emailOrPhone: text})} + /> + + + + {this.props.translate('workspace.invite.personalMessagePrompt')} + + this.setState({welcomeNote: text})} + /> + + {this.props.translate('common.privacy')} + + + + +