diff --git a/src/CONST.js b/src/CONST.js index 3298d36b7880..4e034327cab1 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -703,6 +703,7 @@ const CONST = { LARGE: 'large', DEFAULT: 'default', SMALL: 'small', + SMALLER: 'smaller', SUBSCRIPT: 'subscript', SMALL_SUBSCRIPT: 'small-subscript', }, diff --git a/src/ROUTES.js b/src/ROUTES.js index 0cd8b4164a8e..0dd0f61824e2 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -23,6 +23,7 @@ export default { SETTINGS: 'settings', SETTINGS_PROFILE: 'settings/profile', SETTINGS_PREFERENCES: 'settings/preferences', + SETTINGS_WORKSPACES: 'settings/workspaces', SETTINGS_SECURITY: 'settings/security', SETTINGS_CLOSE: 'settings/security/closeAccount', SETTINGS_PASSWORD: 'settings/security/password', diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index 279e4c86cf54..5241714bea22 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -35,7 +35,7 @@ const BlockingView = props => ( width={variables.iconSizeSuperLarge} height={variables.iconSizeSuperLarge} /> - {props.title} + {props.title} {props.subtitle} ); diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 0ea32095698f..a9cc1f057caf 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -16,6 +16,7 @@ import menuItemPropTypes from './menuItemPropTypes'; import SelectCircle from './SelectCircle'; import colors from '../styles/colors'; import variables from '../styles/variables'; +import MultipleAvatars from './MultipleAvatars'; const propTypes = { ...menuItemPropTypes, @@ -46,6 +47,8 @@ const defaultProps = { interactive: true, fallbackIcon: Expensicons.FallbackAvatar, brickRoadIndicator: '', + floatRightAvatars: [], + shouldStackHorizontally: false, }; const MenuItem = (props) => { @@ -150,8 +153,18 @@ const MenuItem = (props) => { )} + {!_.isEmpty(props.floatRightAvatars) && ( + + + + )} {Boolean(props.brickRoadIndicator) && ( - + { @@ -39,12 +47,13 @@ const MultipleAvatars = (props) => { props.size === CONST.AVATAR_SIZE.SMALL ? styles.secondAvatarSmall : styles.secondAvatar, ...props.secondAvatarStyle, ]; + const horizontalStyles = [styles.horizontalStackedAvatar4, styles.horizontalStackedAvatar3, styles.horizontalStackedAvatar2, styles.horizontalStackedAvatar1]; if (!props.icons.length) { return null; } - if (props.icons.length === 1) { + if (props.icons.length === 1 && !props.shouldStackHorizontally) { return ( @@ -60,41 +69,67 @@ const MultipleAvatars = (props) => { return ( - - - - - - {props.icons.length === 2 ? ( - - - - ) : ( - + {props.shouldStackHorizontally ? ( + <> + { + _.map([...props.icons].splice(0, 4).reverse(), (icon, index) => ( - - {`+${props.icons.length - 1}`} - + - + )) + } + {props.icons.length > 4 && ( + + + {`+${props.icons.length - 4}`} + + )} + + ) : ( + + + + + + {props.icons.length === 2 ? ( + + + + ) : ( + + + + {`+${props.icons.length - 1}`} + + + + )} + - + )} ); }; diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 7f38175d564e..949d1ca26f1a 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -76,8 +76,14 @@ const propTypes = { /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ fallbackIcon: PropTypes.func, + /** Avatars to show on the right of the menu item */ + floatRightAvatars: PropTypes.arrayOf(PropTypes.string), + /** The type of brick road indicator to show. */ brickRoadIndicator: PropTypes.oneOf([CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, '']), + + /** Prop to identify if we should load avatars vertically instead of diagonally */ + shouldStackHorizontally: PropTypes.bool, }; export default propTypes; diff --git a/src/languages/en.js b/src/languages/en.js index 939a60d94665..6b2ac17c596e 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -19,6 +19,7 @@ export default { save: 'Save', saveChanges: 'Save changes', password: 'Password', + workspaces: 'Workspaces', profile: 'Profile', payments: 'Payments', preferences: 'Preferences', @@ -814,6 +815,10 @@ export default { growlMessageOnDeleteError: 'This workspace cannot be deleted right now because reports are actively being processed', unavailable: 'Unavailable workspace', }, + emptyWorkspace: { + title: 'Create a new workspace', + subtitle: 'Workspaces are where you\'ll chat with your team, reimburse expenses, issue cards, send invoices, pay bills, and more — all in one place.', + }, new: { newWorkspace: 'New workspace', getTheExpensifyCardAndMore: 'Get the Expensify Card and more', diff --git a/src/languages/es.js b/src/languages/es.js index 31f7161565b9..eb7420a24cf5 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -19,6 +19,7 @@ export default { save: 'Guardar', saveChanges: 'Guardar cambios', password: 'Contraseña', + workspaces: 'Espacios de trabajo', profile: 'Perfil', payments: 'Pagos', preferences: 'Preferencias', @@ -816,6 +817,10 @@ export default { growlMessageOnDeleteError: 'No se puede eliminar el espacio de trabajo porque tiene informes que están siendo procesados', unavailable: 'Espacio de trabajo no disponible', }, + emptyWorkspace: { + title: 'Crear un nuevo espacio de trabajo', + subtitle: 'En los espacios de trabajo es donde puedes chatear con tu equipo, reembolsar gastos, emitir tarjetas, enviar y pagar facturas y mas — todo en un mismo lugar', + }, new: { newWorkspace: 'Nuevo espacio de trabajo', getTheExpensifyCardAndMore: 'Consigue la Tarjeta Expensify y más', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 6702b90769d8..7a22f47572fc 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -203,6 +203,13 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Settings_Root', }, + { + getComponent: () => { + const SettingsWorkspacesPage = require('../../../pages/workspace/WorkspacesListPage').default; + return SettingsWorkspacesPage; + }, + name: 'Settings_Workspaces', + }, { getComponent: () => { const SettingsProfilePage = require('../../../pages/settings/Profile/ProfilePage').default; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 23cfa9ea349c..6e8951bffbcf 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -36,6 +36,10 @@ export default { Settings_Root: { path: ROUTES.SETTINGS, }, + Settings_Workspaces: { + path: ROUTES.SETTINGS_WORKSPACES, + exact: true, + }, Settings_Preferences: { path: ROUTES.SETTINGS_PREFERENCES, exact: true, diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 564fc2d0b717..1f481e08802d 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -5,10 +5,8 @@ import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; import styles from '../../styles/styles'; -import themeColors from '../../styles/themes/default'; import Text from '../../components/Text'; import * as Session from '../../libs/actions/Session'; -import * as Policy from '../../libs/actions/Policy'; import ONYXKEYS from '../../ONYXKEYS'; import Tooltip from '../../components/Tooltip'; import Avatar from '../../components/Avatar'; @@ -24,14 +22,12 @@ import CONST from '../../CONST'; import Permissions from '../../libs/Permissions'; import * as App from '../../libs/actions/App'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../components/withCurrentUserPersonalDetails'; -import * as PolicyUtils from '../../libs/PolicyUtils'; -import policyMemberPropType from '../policyMemberPropType'; import * as PaymentMethods from '../../libs/actions/PaymentMethods'; import bankAccountPropTypes from '../../components/bankAccountPropTypes'; import cardPropTypes from '../../components/cardPropTypes'; import * as Wallet from '../../libs/actions/Wallet'; -import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import walletTermsPropTypes from '../EnablePayments/walletTermsPropTypes'; +import * as PolicyUtils from '../../libs/PolicyUtils'; const propTypes = { /* Onyx Props */ @@ -60,9 +56,6 @@ const propTypes = { pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), })), - /** List of policy members */ - policyMembers: PropTypes.objectOf(policyMemberPropType), - /** The user's wallet account */ userWallet: PropTypes.shape({ /** The user's current wallet balance */ @@ -92,32 +85,16 @@ const defaultProps = { currentBalance: 0, }, betas: [], - policyMembers: {}, walletTerms: {}, ...withCurrentUserPersonalDetailsDefaultProps, }; -/** - * Dismisses the errors on one item - * - * @param {string} policyID - * @param {string} pendingAction - */ -function dismissWorkspaceError(policyID, pendingAction) { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - Policy.clearDeleteWorkspaceError(policyID); - return; - } - throw new Error('Not implemented'); -} - class InitialSettingsPage extends React.Component { constructor(props) { super(props); this.getWalletBalance = this.getWalletBalance.bind(this); this.getDefaultMenuItems = this.getDefaultMenuItems.bind(this); - this.getMenuItemsList = this.getMenuItemsList.bind(this); this.getMenuItem = this.getMenuItem.bind(this); } @@ -142,7 +119,28 @@ class InitialSettingsPage extends React.Component { * @returns {Array} the default menu items */ getDefaultMenuItems() { + const policiesAvatars = _.chain(this.props.policies) + .filter(policy => policy + && policy.type === CONST.POLICY.TYPE.FREE + && policy.role === CONST.POLICY.ROLE.ADMIN + && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .sortBy(policy => policy.name) + .pluck('avatar') + .value(); + const policyBrickRoadIndicator = _.chain(this.props.policies) + .filter(policy => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN) + .find(policy => PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, this.props.policyMembers)) + .value() ? 'error' : null; + return ([ + { + translationKey: 'common.workspaces', + icon: Expensicons.Building, + action: () => { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); }, + floatRightAvatars: policiesAvatars, + shouldStackHorizontally: true, + brickRoadIndicator: policyBrickRoadIndicator, + }, { translationKey: 'common.profile', icon: Expensicons.Profile, @@ -178,65 +176,10 @@ class InitialSettingsPage extends React.Component { ]); } - /** - * Add free policies (workspaces) to the list of menu items and returns the list of menu items - * @returns {Array} the menu item list - */ - getMenuItemsList() { - const menuItems = _.chain(this.props.policies) - .filter(policy => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN) - .map(policy => ({ - title: policy.name, - icon: policy.avatar ? policy.avatar : Expensicons.Building, - iconType: policy.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, - action: () => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policy.id)), - iconStyles: policy.avatar ? [] : [styles.popoverMenuIconEmphasized], - iconFill: themeColors.iconReversed, - fallbackIcon: Expensicons.FallbackWorkspaceAvatar, - brickRoadIndicator: PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, this.props.policyMembers), - pendingAction: policy.pendingAction, - isPolicy: true, - errors: policy.errors, - dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), - disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - })) - .sortBy(policy => policy.title) - .value(); - menuItems.push(...this.getDefaultMenuItems()); - - return menuItems; - } - getMenuItem(item, index) { const keyTitle = item.translationKey ? this.props.translate(item.translationKey) : item.title; const isPaymentItem = item.translationKey === 'common.payments'; - if (item.isPolicy) { - return ( - - - - ); - } - return ( ); } @@ -301,7 +246,7 @@ class InitialSettingsPage extends React.Component { )} - {_.map(this.getMenuItemsList(), (item, index) => this.getMenuItem(item, index))} + {_.map(this.getDefaultMenuItems(), (item, index) => this.getMenuItem(item, index))} diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index fbaf9289979d..52f0ae69d830 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -138,17 +138,13 @@ class WorkspaceInitialPage extends React.Component { Navigation.navigate(ROUTES.SETTINGS)} + onBackButtonPress={() => Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)} onCloseButtonPress={() => Navigation.dismissModal()} shouldShowThreeDotsButton shouldShowGetAssistanceButton guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_INITIAL} threeDotsMenuItems={[ { - icon: Expensicons.Plus, - text: this.props.translate('workspace.new.newWorkspace'), - onSelected: () => Policy.createWorkspace(), - }, { icon: Expensicons.Trashcan, text: this.props.translate('workspace.common.delete'), onSelected: () => this.setState({isDeleteModalOpen: true}), diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js new file mode 100755 index 000000000000..4c9df5ee401a --- /dev/null +++ b/src/pages/workspace/WorkspacesListPage.js @@ -0,0 +1,221 @@ +import React, {Component} from 'react'; +import {ScrollView} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import ROUTES from '../../ROUTES'; +import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; +import styles from '../../styles/styles'; +import compose from '../../libs/compose'; +import OfflineWithFeedback from '../../components/OfflineWithFeedback'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import * as Expensicons from '../../components/Icon/Expensicons'; +import themeColors from '../../styles/themes/default'; +import * as PolicyUtils from '../../libs/PolicyUtils'; +import MenuItem from '../../components/MenuItem'; +import * as Policy from '../../libs/actions/Policy'; +import policyMemberPropType from '../policyMemberPropType'; +import Permissions from '../../libs/Permissions'; +import Button from '../../components/Button'; +import FixedFooter from '../../components/FixedFooter'; +import BlockingView from '../../components/BlockingViews/BlockingView'; + +const propTypes = { + /* Onyx Props */ + + /** The list of this user's policies */ + policies: PropTypes.objectOf(PropTypes.shape({ + /** The ID of the policy */ + ID: PropTypes.string, + + /** The name of the policy */ + name: PropTypes.string, + + /** The type of the policy */ + type: PropTypes.string, + + /** The user's role in the policy */ + role: PropTypes.string, + + /** The current action that is waiting to happen on the policy */ + pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), + })), + + /** List of policy members */ + policyMembers: PropTypes.objectOf(policyMemberPropType), + + /** The user's wallet account */ + userWallet: PropTypes.shape({ + /** The user's current wallet balance */ + currentBalance: PropTypes.number, + }), + + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + policies: {}, + policyMembers: {}, + userWallet: { + currentBalance: 0, + }, + betas: [], +}; + +/** + * Dismisses the errors on one item + * + * @param {string} policyID + * @param {string} pendingAction + */ +function dismissWorkspaceError(policyID, pendingAction) { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + Policy.clearDeleteWorkspaceError(policyID); + return; + } + throw new Error('Not implemented'); +} + +class WorkspacesListPage extends Component { + constructor(props) { + super(props); + + this.getWalletBalance = this.getWalletBalance.bind(this); + this.getWorkspaces = this.getWorkspaces.bind(this); + this.getMenuItem = this.getMenuItem.bind(this); + } + + /** + * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item + * @returns {Number} the user wallet balance + */ + getWalletBalance(isPaymentItem) { + return (isPaymentItem && Permissions.canUseWallet(this.props.betas)) + ? this.props.numberFormat( + this.props.userWallet.currentBalance / 100, // Divide by 100 because balance is in cents + {style: 'currency', currency: 'USD'}, + ) : undefined; + } + + /** + * Add free policies (workspaces) to the list of menu items and returns the list of menu items + * @returns {Array} the menu item list + */ + getWorkspaces() { + return _.chain(this.props.policies) + .filter(policy => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN) + .map(policy => ({ + title: policy.name, + icon: policy.avatar ? policy.avatar : Expensicons.Building, + iconType: policy.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, + action: () => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policy.id)), + iconStyles: policy.avatar ? [] : [styles.popoverMenuIconEmphasized], + iconFill: themeColors.iconReversed, + fallbackIcon: Expensicons.FallbackWorkspaceAvatar, + brickRoadIndicator: PolicyUtils.getPolicyBrickRoadIndicatorStatus(policy, this.props.policyMembers), + pendingAction: policy.pendingAction, + isPolicy: true, + errors: policy.errors, + dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), + disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + })) + .sortBy(policy => policy.title) + .value(); + } + + /** + * Gets the menu item for each workspace + * + * @param {Object} item + * @param {Number} index + * @returns {JSX} + */ + getMenuItem(item, index) { + const keyTitle = item.translationKey ? this.props.translate(item.translationKey) : item.title; + const isPaymentItem = item.translationKey === 'common.payments'; + + return ( + + + + ); + } + + render() { + const workspaces = this.getWorkspaces(); + return ( + + Navigation.navigate(ROUTES.SETTINGS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + {_.isEmpty(workspaces) ? ( + + ) : ( + + {_.map(workspaces, (item, index) => this.getMenuItem(item, index))} + + )} + +