diff --git a/assets/images/three-dots.svg b/assets/images/three-dots.svg new file mode 100644 index 00000000000..4d9b5384986 --- /dev/null +++ b/assets/images/three-dots.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js index 72a84a4ac55..33b4a49d2d1 100755 --- a/src/components/HeaderWithCloseButton.js +++ b/src/components/HeaderWithCloseButton.js @@ -10,6 +10,7 @@ import * as Expensicons from './Icon/Expensicons'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import Tooltip from './Tooltip'; import InboxCallButton from './InboxCallButton'; +import ThreeDotsMenu, {ThreeDotsMenuItemPropTypes} from './ThreeDotsMenu'; const propTypes = { /** Title of the Header */ @@ -24,6 +25,9 @@ const propTypes = { /** Method to trigger when pressing back button of the header */ onBackButtonPress: PropTypes.func, + /** Method to trigger when pressing more options button of the header */ + onThreeDotsButtonPress: PropTypes.func, + /** Whether we should show a back icon */ shouldShowBackButton: PropTypes.bool, @@ -36,6 +40,23 @@ const propTypes = { /** Whether we should show a inbox call button */ shouldShowInboxCallButton: PropTypes.bool, + /** Whether we should show a more options (threedots) button */ + shouldShowThreeDotsButton: PropTypes.bool, + + /** List of menu items for more(three dots) menu */ + threeDotsMenuItems: ThreeDotsMenuItemPropTypes, + + /** The anchor position of the menu */ + threeDotsAnchorPosition: PropTypes.shape({ + top: PropTypes.number, + right: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + }), + + /** Whether we should show a close button */ + shouldShowCloseButton: PropTypes.bool, + /** Whether we should show the step counter */ shouldShowStepCounter: PropTypes.bool, @@ -56,13 +77,21 @@ const defaultProps = { onDownloadButtonPress: () => {}, onCloseButtonPress: () => {}, onBackButtonPress: () => {}, + onThreeDotsButtonPress: () => {}, shouldShowBackButton: false, shouldShowBorderBottom: false, shouldShowDownloadButton: false, shouldShowInboxCallButton: false, + shouldShowThreeDotsButton: false, + shouldShowCloseButton: true, shouldShowStepCounter: true, inboxCallTaskID: '', stepCounter: null, + threeDotsMenuItems: [], + threeDotsAnchorPosition: { + top: 0, + left: 0, + }, }; const HeaderWithCloseButton = props => ( @@ -107,6 +136,17 @@ const HeaderWithCloseButton = props => ( {props.shouldShowInboxCallButton && } + {props.shouldShowThreeDotsButton && ( + + )} + + {props.shouldShowCloseButton + && ( ( + )} diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 973b6108114..1bac7931e36 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -61,6 +61,7 @@ import Users from '../../../assets/images/users.svg'; import Venmo from '../../../assets/images/venmo.svg'; import Wallet from '../../../assets/images/wallet.svg'; import Workspace from '../../../assets/images/workspace-default-avatar.svg'; +import ThreeDots from '../../../assets/images/three-dots.svg'; export { Android, @@ -126,4 +127,5 @@ export { Venmo, Wallet, Workspace, + ThreeDots, }; diff --git a/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js b/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js new file mode 100644 index 00000000000..880e067fb39 --- /dev/null +++ b/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +const menuItemProps = PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string]), + text: PropTypes.string, + onPress: PropTypes.func, +})); + +export default menuItemProps; diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js new file mode 100644 index 00000000000..27ebb22b543 --- /dev/null +++ b/src/components/ThreeDotsMenu/index.js @@ -0,0 +1,111 @@ +import React, {Component} from 'react'; +import { + View, Pressable, +} from 'react-native'; +import PropTypes from 'prop-types'; +import Icon from '../Icon'; +import PopoverMenu from '../PopoverMenu'; +import styles from '../../styles/styles'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Tooltip from '../Tooltip'; +import * as Expensicons from '../Icon/Expensicons'; +import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes'; + +const propTypes = { + ...withLocalizePropTypes, + + /** Tooltip for the popup icon */ + iconTooltip: PropTypes.string, + + /** icon for the popup trigger */ + icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string]), + + /** Any additional styles to pass to the icon container. */ + iconStyles: PropTypes.arrayOf(PropTypes.object), + + /** The fill color to pass into the icon. */ + iconFill: PropTypes.string, + + /** Function to call on icon press */ + onIconPress: PropTypes.func, + + /** menuItems that'll show up on toggle of the popup menu */ + menuItems: ThreeDotsMenuItemPropTypes.isRequired, + + /** The anchor position of the menu */ + anchorPosition: PropTypes.shape({ + top: PropTypes.number, + right: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + }).isRequired, +}; + +const defaultProps = { + iconTooltip: 'common.more', + iconFill: undefined, + iconStyles: [], + icon: Expensicons.ThreeDots, + onIconPress: () => {}, +}; + +class ThreeDotsMenu extends Component { + constructor(props) { + super(props); + + this.togglePopupMenu = this.togglePopupMenu.bind(this); + this.state = { + isPopupMenuVisible: false, + }; + } + + /** + * Toggles the popup menu visibility + */ + togglePopupMenu() { + this.setState(prevState => ({ + isPopupMenuVisible: !prevState.isPopupMenuVisible, + })); + } + + render() { + return ( + <> + + + { + this.togglePopupMenu(); + if (this.props.onIconPress) { + this.props.onIconPress(); + } + }} + style={[styles.touchableButtonImage, ...this.props.iconStyles]} + > + + + + + this.togglePopupMenu()} + animationIn="fadeInDown" + animationOut="fadeOutUp" + menuItems={this.props.menuItems} + /> + + ); + } +} + +ThreeDotsMenu.propTypes = propTypes; +ThreeDotsMenu.defaultProps = defaultProps; +ThreeDotsMenu.displayName = 'ThreeDotsMenu'; +export default withLocalize(ThreeDotsMenu); + +export {ThreeDotsMenuItemPropTypes}; diff --git a/src/languages/en.js b/src/languages/en.js index f1b3e9c428f..0165ae122a5 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -85,6 +85,7 @@ export default { confirm: 'Confirm', reset: 'Reset', done: 'Done', + more: 'More', debitCard: 'Debit card', payPalMe: 'PayPal.me', }, @@ -655,6 +656,8 @@ export default { common: { card: 'Issue corporate cards', workspace: 'Workspace', + edit: 'Edit workspace', + delete: 'Delete Workspace', settings: 'General settings', reimburse: 'Reimburse receipts', bills: 'Pay bills', @@ -666,6 +669,8 @@ export default { issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', growlMessageOnSave: 'Your workspace settings were successfully saved!', + deleteConfirmation: 'Are you sure you want to delete this workspace?', + growlMessageOnDelete: 'Workspace deleted', }, new: { newWorkspace: 'New workspace', diff --git a/src/languages/es.js b/src/languages/es.js index c2824417ab6..076df5f033a 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -85,6 +85,7 @@ export default { confirm: 'Confirmar', reset: 'Restablecer', done: 'Listo', + more: 'Más', debitCard: 'Tarjeta de débito', payPalMe: 'PayPal.me', }, @@ -657,6 +658,8 @@ export default { common: { card: 'Emitir tarjetas corporativas', workspace: 'Espacio de trabajo', + edit: 'Editar espacio de trabajo', + delete: 'Eliminar espacio de trabajo', settings: 'Configuración general', reimburse: 'Reembolsar recibos', bills: 'Pagar facturas', @@ -668,6 +671,8 @@ export default { issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', growlMessageOnSave: '¡La configuración del espacio de trabajo se ha guardado correctamente!', + growlMessageOnDelete: 'Espacio de trabajo eliminado', + deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', }, new: { newWorkspace: 'Nuevo espacio de trabajo', diff --git a/src/libs/API.js b/src/libs/API.js index 4124dd134f9..94b48e42b45 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -1050,6 +1050,16 @@ function Policy_Create(parameters) { return Network.post(commandName, parameters); } +/** + * @param {Object} parameters + * @param {String} [parameters.policyID] + * @returns {Promise} + */ +function Policy_Delete(parameters) { + const commandName = 'Policy_Delete'; + return Network.post(commandName, parameters); +} + /** * @param {Object} parameters * @param {String} parameters.policyID @@ -1175,4 +1185,5 @@ export { Policy_Create, Policy_Employees_Remove, PreferredLocale_Update, + Policy_Delete, }; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 344d448eef8..1a828be9365 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -4,8 +4,8 @@ import {Keyboard} from 'react-native'; import { StackActions, DrawerActions, - useLinkBuilder, createNavigationContainerRef, + getPathFromState, } from '@react-navigation/native'; import PropTypes from 'prop-types'; import Onyx from 'react-native-onyx'; @@ -15,6 +15,7 @@ import ROUTES from '../../ROUTES'; import SCREENS from '../../SCREENS'; import CustomActions from './CustomActions'; import ONYXKEYS from '../../ONYXKEYS'; +import linkingConfig from './linkingConfig'; let isLoggedIn = false; Onyx.connect({ @@ -151,21 +152,16 @@ function dismissModal(shouldOpenDrawer = false) { /** * Check whether the passed route is currently Active or not. * - * Building path with useLinkBuilder since navigationRef.current.getCurrentRoute().path + * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path * is undefined in the first navigation. * * @param {String} routePath Path to check * @return {Boolean} is active */ function isActiveRoute(routePath) { - const buildLink = useLinkBuilder(); - // We remove First forward slash from the URL before matching const path = navigationRef.current && navigationRef.current.getCurrentRoute().name - ? buildLink( - navigationRef.current.getCurrentRoute().name, - navigationRef.current.getCurrentRoute().params, - ).substring(1) + ? getPathFromState(navigationRef.current.getState(), linkingConfig.config).substring(1) : ''; return path === routePath; } diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 80c09f1f620..fe4f1af5151 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -124,7 +124,6 @@ function create(name = '') { * @param {String} policyID */ function navigateToPolicy(policyID) { - Navigation.dismissModal(); Navigation.navigate(policyID ? ROUTES.getWorkspaceInitialRoute(policyID) : ROUTES.HOME); } @@ -135,6 +134,33 @@ function createAndNavigate(name = '') { create(name).then(navigateToPolicy); } +/** + * Delete the policy + * + * @param {String} [policyID] + * @returns {Promise} + */ +function deletePolicy(policyID) { + return API.Policy_Delete({policyID}) + .then((response) => { + if (response.jsonCode !== 200) { + // Show the user feedback + const errorMessage = Localize.translateLocal('workspace.new.genericFailureMessage'); + Growl.error(errorMessage, 5000); + return; + } + + Growl.show(Localize.translateLocal('workspace.common.growlMessageOnDelete'), CONST.GROWL.SUCCESS, 3000); + + // Removing the workspace data from Onyx as well + return Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); + }).then(() => { + Navigation.dismissModal(); + Navigation.navigate(ROUTES.HOME); + return Promise.resolve(); + }); +} + /** * Fetches policy list from the API and saves a simplified version in Onyx, optionally creating a new policy first. * @@ -381,6 +407,7 @@ export { update, setWorkspaceErrors, hideWorkspaceAlertMessage, + deletePolicy, createAndNavigate, createAndGetPolicyList, }; diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 37d8a09326b..ffd04c654b7 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -8,6 +8,7 @@ import ROUTES from '../../ROUTES'; import styles from '../../styles/styles'; import Text from '../../components/Text'; import Tooltip from '../../components/Tooltip'; +import ConfirmModal from '../../components/ConfirmModal'; import Icon from '../../components/Icon'; import * as Expensicons from '../../components/Icon/Expensicons'; import ScreenWrapper from '../../components/ScreenWrapper'; @@ -20,6 +21,7 @@ import compose from '../../libs/compose'; import Avatar from '../../components/Avatar'; import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; import withFullPolicy, {fullPolicyPropTypes, fullPolicyDefaultProps} from './withFullPolicy'; +import * as PolicyActions from '../../libs/actions/Policy'; const propTypes = { /** Whether the current screen is focused. */ @@ -32,149 +34,205 @@ const propTypes = { const defaultProps = fullPolicyDefaultProps; -const WorkspaceInitialPage = (props) => { - if (_.isEmpty(props.policy)) { - return ; +class WorkspaceInitialPage extends React.Component { + constructor(props) { + super(props); + + const policy = this.props.policy; + this.openEditor = this.openEditor.bind(this); + this.toggleDeleteModal = this.toggleDeleteModal.bind(this); + this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); + + this.menuItems = [ + { + translationKey: 'workspace.common.settings', + icon: Expensicons.Gear, + action: () => Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceSettingsRoute(policy.id)), + }, + { + translationKey: 'workspace.common.card', + icon: Expensicons.ExpensifyCard, + action: () => Navigation.navigate(ROUTES.getWorkspaceCardRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceCardRoute(policy.id)), + }, + { + translationKey: 'workspace.common.reimburse', + icon: Expensicons.Receipt, + action: () => Navigation.navigate(ROUTES.getWorkspaceReimburseRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceReimburseRoute(policy.id)), + }, + { + translationKey: 'workspace.common.bills', + icon: Expensicons.Bill, + action: () => Navigation.navigate(ROUTES.getWorkspaceBillsRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBillsRoute(policy.id)), + }, + { + translationKey: 'workspace.common.invoices', + icon: Expensicons.Invoice, + action: () => Navigation.navigate(ROUTES.getWorkspaceInvoicesRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceInvoicesRoute(policy.id)), + }, + { + translationKey: 'workspace.common.travel', + icon: Expensicons.Luggage, + action: () => Navigation.navigate(ROUTES.getWorkspaceTravelRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceTravelRoute(policy.id)), + }, + { + translationKey: 'workspace.common.members', + icon: Expensicons.Users, + action: () => Navigation.navigate(ROUTES.getWorkspaceMembersRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceMembersRoute(policy.id)), + }, + { + translationKey: 'workspace.common.bankAccount', + icon: Expensicons.Bank, + action: () => Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(policy.id)), + isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBankAccountRoute(policy.id)), + }, + ]; + + this.state = { + isDeleteModalOpen: false, + }; } - const menuItems = [ - { - translationKey: 'workspace.common.settings', - icon: Expensicons.Gear, - action: () => Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceSettingsRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.card', - icon: Expensicons.ExpensifyCard, - action: () => Navigation.navigate(ROUTES.getWorkspaceCardRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceCardRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.reimburse', - icon: Expensicons.Receipt, - action: () => Navigation.navigate(ROUTES.getWorkspaceReimburseRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceReimburseRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.bills', - icon: Expensicons.Bill, - action: () => Navigation.navigate(ROUTES.getWorkspaceBillsRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBillsRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.invoices', - icon: Expensicons.Invoice, - action: () => Navigation.navigate(ROUTES.getWorkspaceInvoicesRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceInvoicesRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.travel', - icon: Expensicons.Luggage, - action: () => Navigation.navigate(ROUTES.getWorkspaceTravelRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceTravelRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.members', - icon: Expensicons.Users, - action: () => Navigation.navigate(ROUTES.getWorkspaceMembersRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceMembersRoute(props.policy.id)), - }, - { - translationKey: 'workspace.common.bankAccount', - icon: Expensicons.Bank, - action: () => Navigation.navigate(ROUTES.getWorkspaceBankAccountRoute(props.policy.id)), - isActive: Navigation.isActiveRoute(ROUTES.getWorkspaceBankAccountRoute(props.policy.id)), - }, - ]; + /** + * Open Workspace Editor + */ + openEditor() { Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(this.props.policy.id)); } - const openEditor = () => Navigation.navigate(ROUTES.getWorkspaceSettingsRoute(props.policy.id)); + /** + * Toggle delete confirm modal visibility + * @param {Boolean} shouldOpen + */ + toggleDeleteModal(shouldOpen) { + this.setState({isDeleteModalOpen: shouldOpen}); + } - return ( - - Navigation.navigate(ROUTES.SETTINGS)} - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - - - - {props.policy.avatarURL - ? ( - - ) - : ( - - )} - + /** + * Call the delete policy and hide the modal + */ + confirmDeleteAndHideModal() { + PolicyActions.deletePolicy(this.props.policy.id); + this.toggleDeleteModal(false); + } - {!_.isEmpty(props.policy.name) && ( + + render() { + if (_.isEmpty(this.props.policy)) { + return ; + } + + return ( + + Navigation.navigate(ROUTES.SETTINGS)} + onCloseButtonPress={() => Navigation.dismissModal()} + shouldShowThreeDotsButton + threeDotsMenuItems={[ + { + icon: Expensicons.Plus, + text: this.props.translate('workspace.new.newWorkspace'), + onSelected: () => PolicyActions.createAndNavigate(), + }, { + icon: Expensicons.Trashcan, + text: this.props.translate('workspace.common.delete'), + onSelected: () => this.setState({isDeleteModalOpen: true}), + }, + ]} + threeDotsAnchorPosition={styles.threeDotsPopoverOffset} + /> + + + + - - - {props.policy.name} - - + {this.props.policy.avatarURL + ? ( + + ) + : ( + + )} - )} + {!_.isEmpty(this.props.policy.name) && ( + + + + {this.props.policy.name} + + + + )} + + {_.map(this.menuItems, (item) => { + const shouldFocus = this.props.isSmallScreenWidth ? !this.props.isFocused && item.isActive : item.isActive; + return ( + item.action()} + wrapperStyle={shouldFocus ? styles.activeComponentBG : undefined} + focused={shouldFocus} + shouldShowRightIcon + /> + ); + })} - {_.map(menuItems, (item) => { - const shouldFocus = props.isSmallScreenWidth ? !props.isFocused && item.isActive : item.isActive; - return ( - item.action()} - wrapperStyle={shouldFocus ? styles.activeComponentBG : undefined} - focused={shouldFocus} - shouldShowRightIcon - /> - ); - })} - - - - ); -}; + + this.toggleDeleteModal(false)} + prompt={this.props.translate('workspace.common.deleteConfirmation')} + confirmText={this.props.translate('common.delete')} + cancelText={this.props.translate('common.cancel')} + /> + + ); + } +} WorkspaceInitialPage.propTypes = propTypes; WorkspaceInitialPage.defaultProps = defaultProps; diff --git a/src/styles/styles.js b/src/styles/styles.js index c7ec40e0ee5..2043e831d11 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2208,6 +2208,15 @@ const styles = { flex: 1, }, + threeDotsPopoverOffset: { + top: 50, + right: 60, + }, + + googleListView: { + transform: [{scale: 0}], + }, + keyboardShortcutModalContainer: { maxWidth: 600, maxHeight: '100%', @@ -2249,10 +2258,6 @@ const styles = { borderTopWidth: 0, }, - googleListView: { - transform: [{scale: 0}], - }, - iPhoneXSafeArea: { backgroundColor: colors.black, flex: 1,