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')}
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+WorkspaceInvitePage.propTypes = propTypes;
+WorkspaceInvitePage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ policy: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`,
+ },
+ }),
+)(WorkspaceInvitePage);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index daa8148bec93..8e5dccda69b6 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -1530,6 +1530,10 @@ const styles = {
cursorDisabled: {
cursor: 'not-allowed',
},
+
+ workspaceInviteWelcome: {
+ minHeight: 150,
+ },
};
const baseCodeTagStyles = {