diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index dcde002e15e9..ff2c32f3d94d 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -48,6 +48,9 @@ export default { // Contains all the private personal details of the user PRIVATE_PERSONAL_DETAILS: 'private_personalDetails', + // Contains all the info for Tasks + TASK: 'task', + // Contains a list of all currencies available to the user - user can // select a currency based on the list CURRENCY_LIST: 'currencyList', diff --git a/src/ROUTES.js b/src/ROUTES.js index d3fd21c34f32..b4ae96eb5967 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -97,6 +97,11 @@ export default { TASK_DESCRIPTION: 'r/:reportID/description', getTaskReportTitleRoute: reportID => `r/${reportID}/title`, getTaskReportDescriptionRoute: reportID => `r/${reportID}/description`, + NEW_TASK_ASSIGNEE: `${NEW_TASK}/assignee`, + NEW_TASK_SHARE_DESTINATION: `${NEW_TASK}/share-destination`, + NEW_TASK_DETAILS: `${NEW_TASK}/details`, + NEW_TASK_TITLE: `${NEW_TASK}/title`, + NEW_TASK_DESCRIPTION: `${NEW_TASK}/description`, getTaskDetailsRoute: taskID => `task/details/${taskID}`, SEARCH: 'search', SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', @@ -105,10 +110,7 @@ export default { REPORT_PARTICIPANTS: 'r/:reportID/participants', getReportParticipantsRoute: reportID => `r/${reportID}/participants`, REPORT_PARTICIPANT: 'r/:reportID/participants/details', - getReportParticipantRoute: ( - reportID, - login, - ) => `r/${reportID}/participants/details?login=${encodeURIComponent(login)}`, + getReportParticipantRoute: (reportID, login) => `r/${reportID}/participants/details?login=${encodeURIComponent(login)}`, REPORT_WITH_ID_DETAILS: 'r/:reportID/details', getReportDetailsRoute: reportID => `r/${reportID}/details`, REPORT_SETTINGS: 'r/:reportID/settings', diff --git a/src/components/TaskSelectorLink.js b/src/components/TaskSelectorLink.js new file mode 100644 index 000000000000..e203591ee63b --- /dev/null +++ b/src/components/TaskSelectorLink.js @@ -0,0 +1,120 @@ +import React from 'react'; +import {View, TouchableOpacity} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../styles/styles'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import themeColors from '../styles/themes/default'; +import variables from '../styles/variables'; +import Text from './Text'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import * as StyleUtils from '../styles/StyleUtils'; +import DisplayNames from './DisplayNames'; +import MultipleAvatars from './MultipleAvatars'; +import CONST from '../CONST'; +import avatarPropTypes from './avatarPropTypes'; + +const propTypes = { + /** Array of avatar URLs or icons */ + icons: PropTypes.arrayOf(avatarPropTypes), + + /** The title to display */ + text: PropTypes.string, + + /** The description to display */ + alternateText: PropTypes.string, + + /** The function to call when the link is pressed */ + onPress: PropTypes.func.isRequired, + + /** Label for the Link */ + label: PropTypes.string.isRequired, + + /** Whether it is a share location */ + isShareDestination: PropTypes.bool, + + /** Whether the Touchable should be disabled */ + disabled: PropTypes.bool, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + icons: [], + text: '', + alternateText: '', + isShareDestination: false, + disabled: false, +}; + +const TaskSelectorLink = (props) => { + const shortenedText = props.text.length > 35 ? `${props.text.substring(0, 35)}...` : props.text; + const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, styles.pre); + const alternateTextStyle = StyleUtils.combineStyles( + styles.sidebarLinkText, + styles.optionAlternateText, + styles.textLabelSupporting, + styles.pre, + ); + const linkBottomMargin = props.icons.length !== 0 ? styles.mb6 : styles.mb2; + return ( + + + {props.icons.length !== 0 || props.text !== '' ? ( + + + {props.translate(props.label)} + + + + + + + {props.alternateText ? ( + + {props.alternateText} + + ) : null} + + + + + ) : ( + + {props.translate(props.label)} + + )} + {props.disabled ? null : ( + + )} + + + ); +}; + +TaskSelectorLink.defaultProps = defaultProps; +TaskSelectorLink.propTypes = propTypes; +TaskSelectorLink.displayName = 'TaskSelectorLink'; + +export default withLocalize(TaskSelectorLink); diff --git a/src/languages/en.js b/src/languages/en.js index 5422adba1af9..3b48f890b05d 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -1153,12 +1153,19 @@ export default { newTaskPage: { task: 'Task', assignTask: 'Assign task', + assignee: 'Assignee', + assigneeError: 'There was an error assigning this task, please try another assignee', + confirmTask: 'Confirm task', + confirmError: 'Please enter a title and select a share destination', title: 'Title', description: 'Description', - shareIn: 'Share in', + descriptionOptional: 'Description (optional)', + shareSomewhere: 'Share somewhere', pleaseEnterTaskName: 'Please enter a title', markAsComplete: 'Mark as complete', markAsIncomplete: 'Mark as incomplete', + pleaseEnterTaskAssignee: 'Please select an assignee', + pleaseEnterTaskDestination: 'Please select a share destination', }, statementPage: { generatingPDF: 'We\'re generating your PDF right now. Please come back later!', diff --git a/src/languages/es.js b/src/languages/es.js index 1fb5b1c1b826..1898baf8aced 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -1154,12 +1154,19 @@ export default { newTaskPage: { task: 'Tarea', assignTask: 'Asignar tarea', + assignee: 'Cesionario', + assigneeError: 'Hubo un error al asignar esta tarea, intente con otro cesionario', + confirmTask: 'Confirmar tarea', + confirmError: 'Por favor introduce un título y selecciona un destino de tarea', title: 'Título', description: 'Descripción', - shareIn: 'Compartir en', + descriptionOptional: 'Descripción (opcional)', + shareSomewhere: 'Compartir en algún lugar', pleaseEnterTaskName: 'Por favor introduce un título', markAsComplete: 'Marcar como completa', markAsIncomplete: 'Marcar como incompleta', + pleaseEnterTaskAssignee: 'Por favor, asigna una persona a esta tarea', + pleaseEnterTaskDestination: 'Por favor, selecciona un destino de tarea', }, statementPage: { generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 3b58d67a2670..87fb89dfdee6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -218,13 +218,50 @@ const NewChatModalStackNavigator = createModalStackNavigator([{ name: 'NewChat_Root', }]); -const NewTaskModalStackNavigator = createModalStackNavigator([{ - getComponent: () => { - const NewTaskPage = require('../../../pages/NewTaskPage').default; - return NewTaskPage; +const NewTaskModalStackNavigator = createModalStackNavigator([ + { + getComponent: () => { + const NewTaskPage = require('../../../pages/tasks/NewTaskPage').default; + return NewTaskPage; + }, + name: 'NewTask_Root', }, - name: 'NewTask_Root', -}]); + { + getComponent: () => { + const NewTaskAssigneeSelectorPage = require('../../../pages/tasks/TaskAssigneeSelectorModal').default; + return NewTaskAssigneeSelectorPage; + }, + name: 'NewTask_TaskAssigneeSelector', + }, + { + getComponent: () => { + const NewTaskTaskShareDestinationPage = require('../../../pages/tasks/TaskShareDestinationSelectorModal').default; + return NewTaskTaskShareDestinationPage; + }, + name: 'NewTask_TaskShareDestinationSelector', + }, + { + getComponent: () => { + const NewTaskDetailsPage = require('../../../pages/tasks/NewTaskDetailsPage').default; + return NewTaskDetailsPage; + }, + name: 'NewTask_Details', + }, + { + getComponent: () => { + const NewTaskTitlePage = require('../../../pages/tasks/NewTaskTitlePage').default; + return NewTaskTitlePage; + }, + name: 'NewTask_Title', + }, + { + getComponent: () => { + const NewTaskDescriptionPage = require('../../../pages/tasks/NewTaskDescriptionPage').default; + return NewTaskDescriptionPage; + }, + name: 'NewTask_Description', + }, +]); const SettingsModalStackNavigator = createModalStackNavigator([ { diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index ddfcdaf411e0..9ce9c763a4d1 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -213,6 +213,11 @@ export default { NewTask: { screens: { NewTask_Root: ROUTES.NEW_TASK_WITH_REPORT_ID, + NewTask_TaskAssigneeSelector: ROUTES.NEW_TASK_ASSIGNEE, + NewTask_TaskShareDestinationSelector: ROUTES.NEW_TASK_SHARE_DESTINATION, + NewTask_Details: ROUTES.NEW_TASK_DETAILS, + NewTask_Title: ROUTES.NEW_TASK_TITLE, + NewTask_Description: ROUTES.NEW_TASK_DESCRIPTION, }, }, Search: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 8f1bb016a7b1..4312ddcf5327 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -832,6 +832,34 @@ function getNewChatOptions( }); } +/** + * Build the options for the Share Destination for a Task + * * + * @param {Object} reports + * @param {Object} personalDetails + * @param {Array} [betas] + * @param {String} [searchValue] + * @param {Array} [selectedOptions] + * @param {Array} [excludeLogins] + * @param {Boolean} [includeOwnedWorkspaceChats] + * @returns {Object} + * + */ + +function getShareDestinationOptions(reports, personalDetails, betas = [], searchValue = '', selectedOptions = [], excludeLogins = [], includeOwnedWorkspaceChats = true) { + return getOptions(reports, personalDetails, { + betas, + searchInputValue: searchValue.trim(), + selectedOptions, + maxRecentReportsToShow: 5, + includeRecentReports: true, + includeMultipleParticipantReports: true, + includePersonalDetails: true, + excludeLogins, + includeOwnedWorkspaceChats, + }); +} + /** * Build the options for the Workspace Member Invite view * @@ -900,6 +928,7 @@ export { isCurrentUser, getSearchOptions, getNewChatOptions, + getShareDestinationOptions, getMemberInviteOptions, getHeaderMessage, getPersonalDetailsForLogins, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 7f7c16db795e..5a97e052cfa2 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -730,12 +730,14 @@ function getIcons(report, personalDetails, defaultIcon = null) { result.source = Expensicons.ActiveRoomAvatar; return [result]; } - if (isPolicyExpenseChat(report) || isExpenseReport(report)) { + if (isPolicyExpenseChat(report)) { const workspaceName = lodashGet(allPolicies, [ `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'name', ]); - const policyExpenseChatAvatarSource = getWorkspaceAvatar(report); + const policyExpenseChatAvatarSource = lodashGet(allPolicies, [ + `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'avatar', + ]) || getDefaultWorkspaceAvatar(workspaceName); // Return the workspace avatar if the user is the owner of the policy expense chat if (report.isOwnPolicyExpenseChat && !isExpenseReport(report)) { @@ -1382,6 +1384,32 @@ function buildOptimisticWorkspaceChats(policyID, policyName) { }; } +/** + * Builds an optimistic Task Report with a randomly generated reportID + * + * @param {String} ownerEmail - Email of the person generating the Task. + * @param {String} assignee - Email of the other person participating in the Task. + * @param {String} parentReportID - Report ID of the chat where the Task is. + * @param {String} title - Task title. + * @param {String} description - Task description. + * + * @returns {Object} + */ + +function buildOptimisticTaskReport(ownerEmail, assignee = null, parentReportID, title, description) { + return { + reportID: generateReportID(), + reportName: title, + description, + ownerEmail, + assignee, + type: CONST.REPORT.TYPE.TASK, + parentReportID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS.OPEN, + }; +} + /** * @param {Object} report * @returns {Boolean} @@ -1599,7 +1627,7 @@ function getChatByParticipants(newParticipantList) { } /** - * Attempts to find a report in onyx with the provided list of participants in given policy +* Attempts to find a report in onyx with the provided list of participants in given policy * @param {Array} newParticipantList * @param {String} policyID * @returns {object|undefined} @@ -1856,6 +1884,7 @@ export { isUnread, isUnreadWithMention, buildOptimisticWorkspaceChats, + buildOptimisticTaskReport, buildOptimisticChatReport, buildOptimisticClosedReportAction, buildOptimisticCreatedReportAction, diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js new file mode 100644 index 000000000000..5118b8b2cfb0 --- /dev/null +++ b/src/libs/actions/Task.js @@ -0,0 +1,219 @@ +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as API from '../API'; +import * as ReportUtils from '../ReportUtils'; +import * as Report from './Report'; +import Navigation from '../Navigation/Navigation'; +import ROUTES from '../../ROUTES'; + +/** + * Clears out the task info from the store + */ +function clearOutTaskInfo() { + Onyx.set(ONYXKEYS.TASK, null); +} + +/** + * Assign a task to a user + * Function title is createTask for consistency with the rest of the actions + * and also because we can create a task without assigning it to anyone + * @param {String} currentUserEmail + * @param {String} parentReportID + * @param {String} title + * @param {String} description + * @param {String} assignee + * + */ + +function createTaskAndNavigate(currentUserEmail, parentReportID, title, description, assignee = '') { + // Create the task report + const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserEmail, assignee, parentReportID, title, description); + + // Grab the assigneeChatReportID if there is an assignee and if it's not the same as the parentReportID + // then we create an optimistic add comment report action on the assignee's chat to notify them of the task + const assigneeChatReportID = lodashGet(ReportUtils.getChatByParticipants([assignee]), 'reportID'); + let optimisticAssigneeAddComment; + if (assigneeChatReportID && assigneeChatReportID !== parentReportID) { + optimisticAssigneeAddComment = ReportUtils.buildOptimisticAddCommentReportAction( + `${currentUserEmail} has[created a task for you](tbd/r/${optimisticTaskReport.reportID}): ${title}`, + ); + optimisticAssigneeAddComment.reportAction.message[0].taskReportID = optimisticTaskReport.reportID; + } + + // Create the CreatedReportAction on the task + const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(optimisticTaskReport.reportID); + + const optimisticAddCommentReport = ReportUtils.buildOptimisticAddCommentReportAction( + `[Created a task](tbd/r/${optimisticTaskReport.reportID}): ${title}`, + ); + optimisticAddCommentReport.reportAction.message[0].taskReportID = optimisticTaskReport.reportID; + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, + value: optimisticTaskReport, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`, + value: {[optimisticTaskCreatedAction.reportActionID]: optimisticTaskCreatedAction}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[optimisticAddCommentReport.reportAction.reportActionID]: optimisticAddCommentReport.reportAction}, + }, + ]; + + if (optimisticAssigneeAddComment) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: optimisticAssigneeAddComment.reportAction}, + }); + } + + const successData = []; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`, + value: {[optimisticTaskCreatedAction.reportActionID]: {pendingAction: null}}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, + value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}}, + }, + ]; + + if (optimisticAssigneeAddComment) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`, + value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}}, + }); + } + + API.write( + 'CreateTask', + { + parentReportActionID: optimisticAddCommentReport.reportAction.reportActionID, + parentReportID, + taskReportID: optimisticTaskReport.reportID, + createdTaskReportActionID: optimisticTaskCreatedAction.reportActionID, + reportName: optimisticTaskReport.reportName, + title: optimisticTaskReport.reportName, + description: optimisticTaskReport.description, + assignee, + assigneeChatReportID, + assigneeChatReportActionID: optimisticAssigneeAddComment ? optimisticAssigneeAddComment.reportAction.reportActionID : 0, + }, + {optimisticData, successData, failureData}, + ); + + clearOutTaskInfo(); + + Navigation.navigate(ROUTES.getReportRoute(optimisticTaskReport.reportID)); +} + +/** + * Sets the title and description values for the task + * @param {string} title + @param {string} description + */ + +function setDetailsValue(title, description) { + // This is only needed for creation of a new task and so it should only be stored locally + Onyx.merge(ONYXKEYS.TASK, {title, description}); +} + +/** + * Sets the title value for the task + * @param {string} title + */ +function setTitleValue(title) { + Onyx.merge(ONYXKEYS.TASK, {title}); +} + +/** + * Sets the description value for the task + * @param {string} description + */ +function setDescriptionValue(description) { + Onyx.merge(ONYXKEYS.TASK, {description}); +} + +/** + * Sets the shareDestination value for the task + * @param {string} shareDestination + */ +function setShareDestinationValue(shareDestination) { + // This is only needed for creation of a new task and so it should only be stored locally + Onyx.merge(ONYXKEYS.TASK, {shareDestination}); +} + +/** + * Sets the assignee value for the task and checks for an existing chat with the assignee + * If there is no existing chat, it creates an optimistic chat report + * It also sets the shareDestination as that chat report if a share destination isn't already set + * @param {string} assignee + * @param {string} shareDestination + */ + +function setAssigneeValue(assignee, shareDestination) { + let newChat = {}; + const chat = ReportUtils.getChatByParticipants([assignee]); + if (!chat) { + newChat = ReportUtils.buildOptimisticChatReport([assignee]); + } + const reportID = chat ? chat.reportID : newChat.reportID; + + if (!shareDestination) { + setShareDestinationValue(reportID); + } + + Report.openReport(reportID, [assignee], newChat); + + // This is only needed for creation of a new task and so it should only be stored locally + Onyx.merge(ONYXKEYS.TASK, {assignee}); +} + +/** + * Sets the parentReportID value for the task + * @param {string} parentReportID + */ + +function setParentReportID(parentReportID) { + // This is only needed for creation of a new task and so it should only be stored locally + Onyx.merge(ONYXKEYS.TASK, {parentReportID}); +} + +/** + * Clears out the task info from the store and navigates to the NewTaskDetails page + * @param {string} reportID + */ +function clearOutTaskInfoAndNavigate(reportID) { + clearOutTaskInfo(); + setParentReportID(reportID); + Navigation.navigate(ROUTES.NEW_TASK_DETAILS); +} + +export { + createTaskAndNavigate, + setTitleValue, + setDescriptionValue, + setDetailsValue, + setAssigneeValue, + setShareDestinationValue, + clearOutTaskInfo, + clearOutTaskInfoAndNavigate, +}; diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 556a0b624412..c21ccef92c9a 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -56,6 +56,7 @@ import KeyboardShortcut from '../../../libs/KeyboardShortcut'; import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; +import * as TaskUtils from '../../../libs/actions/Task'; const propTypes = { /** Beta features list */ @@ -431,7 +432,7 @@ class ReportActionCompose extends React.Component { { icon: Expensicons.Task, text: this.props.translate('newTaskPage.assignTask'), - onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(this.props.reportID)), + onSelected: () => TaskUtils.clearOutTaskInfoAndNavigate(this.props.reportID), }, ]; } diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index ebff5f4e6a51..0a3f48c58404 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -21,6 +21,7 @@ import withNavigation from '../../../../components/withNavigation'; import * as Welcome from '../../../../libs/actions/Welcome'; import withNavigationFocus from '../../../../components/withNavigationFocus'; import withDrawerState from '../../../../components/withDrawerState'; +import * as TaskUtils from '../../../../libs/actions/Task'; /** * @param {Object} [policy] @@ -215,7 +216,7 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.Task, text: this.props.translate('newTaskPage.assignTask'), - onSelected: () => Navigation.navigate(ROUTES.NEW_TASK), + onSelected: () => TaskUtils.clearOutTaskInfoAndNavigate(), }, ] : []), ...(!this.props.isLoading && !Policy.hasActiveFreePolicy(this.props.allPolicies) ? [ diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js new file mode 100644 index 000000000000..b910d29c58cb --- /dev/null +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -0,0 +1,100 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import styles from '../../styles/styles'; +import ONYXKEYS from '../../ONYXKEYS'; +import Form from '../../components/Form'; +import TextInput from '../../components/TextInput'; +import Permissions from '../../libs/Permissions'; +import ROUTES from '../../ROUTES'; +import * as TaskUtils from '../../libs/actions/Task'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** Grab the Share description of the Task */ + task: PropTypes.shape({ + /** Description of the Task */ + description: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + betas: [], + task: { + description: '', + }, +}; + +const NewTaskDescriptionPage = (props) => { + /** + * @param {Object} values - form input values passed by the Form component + * @returns {Object} + */ + function validate() { + return {}; + } + + // On submit, we want to call the assignTask function and wait to validate + // the response + const onSubmit = (values) => { + TaskUtils.setDescriptionValue(values.taskDescription); + Navigation.navigate(ROUTES.NEW_TASK); + }; + + if (!Permissions.canUseTasks(props.betas)) { + Navigation.dismissModal(); + return null; + } + return ( + + Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> +
onSubmit(values)} + validate={() => validate()} + enabledWhenOffline + > + + + +
+
+ ); +}; + +NewTaskDescriptionPage.displayName = 'NewTaskDescriptionPage'; +NewTaskDescriptionPage.propTypes = propTypes; +NewTaskDescriptionPage.defaultProps = defaultProps; + +export default compose( + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + task: { + key: ONYXKEYS.TASK, + }, + }), + withLocalize, +)(NewTaskDescriptionPage); diff --git a/src/pages/NewTaskPage.js b/src/pages/tasks/NewTaskDetailsPage.js similarity index 56% rename from src/pages/NewTaskPage.js rename to src/pages/tasks/NewTaskDetailsPage.js index a93025dfd5a6..cc1353839096 100644 --- a/src/pages/NewTaskPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -1,31 +1,35 @@ -import React from 'react'; +import React, {useRef} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; -import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; -import compose from '../libs/compose'; -import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; -import Navigation from '../libs/Navigation/Navigation'; -import ScreenWrapper from '../components/ScreenWrapper'; -import styles from '../styles/styles'; -import ONYXKEYS from '../ONYXKEYS'; -import * as ErrorUtils from '../libs/ErrorUtils'; -import Form from '../components/Form'; -import TextInput from '../components/TextInput'; -import Permissions from '../libs/Permissions'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import styles from '../../styles/styles'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as ErrorUtils from '../../libs/ErrorUtils'; +import Form from '../../components/Form'; +import TextInput from '../../components/TextInput'; +import Permissions from '../../libs/Permissions'; +import ROUTES from '../../ROUTES'; +import * as TaskUtils from '../../libs/actions/Task'; const propTypes = { - /** List of betas available to current user */ + /** Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), ...withLocalizePropTypes, }; + const defaultProps = { betas: [], }; -// NOTE: This page is going to be updated in https://github.com/Expensify/App/issues/16855, this is just a placeholder for now const NewTaskPage = (props) => { + const inputRef = useRef(); + /** * @param {Object} values - form input values passed by the Form component * @returns {Boolean} @@ -34,39 +38,46 @@ const NewTaskPage = (props) => { const errors = {}; if (!values.taskTitle) { - // We error if the user doesn't enter a room name + // We error if the user doesn't enter a task name ErrorUtils.addErrorMessage(errors, 'taskTitle', props.translate('newTaskPage.pleaseEnterTaskName')); } return errors; } - function onSubmit() { - + // On submit, we want to call the assignTask function and wait to validate + // the response + function onSubmit(values) { + TaskUtils.setDetailsValue(values.taskTitle, values.taskDescription); + Navigation.navigate(ROUTES.NEW_TASK); } if (!Permissions.canUseTasks(props.betas)) { Navigation.dismissModal(); return null; } - return ( - + inputRef.current && inputRef.current.focus()} + includeSafeAreaPaddingBottom={false} + > Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} />
validate(values)} - onSubmit={() => onSubmit()} + onSubmit={values => onSubmit(values)} enabledWhenOffline > inputRef.current = el} inputID="taskTitle" label={props.translate('newTaskPage.title')} /> @@ -74,7 +85,7 @@ const NewTaskPage = (props) => { diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js new file mode 100644 index 000000000000..3ada2e9e06fd --- /dev/null +++ b/src/pages/tasks/NewTaskPage.js @@ -0,0 +1,243 @@ +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import styles from '../../styles/styles'; +import ONYXKEYS from '../../ONYXKEYS'; +import Permissions from '../../libs/Permissions'; +import ROUTES from '../../ROUTES'; +import TaskSelectorLink from '../../components/TaskSelectorLink'; +import reportPropTypes from '../reportPropTypes'; +import * as ReportUtils from '../../libs/ReportUtils'; +import * as TaskUtils from '../../libs/actions/Task'; +import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; + +const propTypes = { + /** Task Creation Data */ + task: PropTypes.shape({ + assignee: PropTypes.string, + shareDestination: PropTypes.string, + title: PropTypes.string, + description: PropTypes.string, + parentReportID: PropTypes.string, + }), + + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All of the personal details for everyone */ + personalDetails: PropTypes.objectOf( + PropTypes.shape({ + /** Display name of the person */ + displayName: PropTypes.string, + + /** Avatar URL of the person */ + avatar: PropTypes.string, + + /** Login of the person */ + login: PropTypes.string, + }), + ), + + /** Current user session */ + session: PropTypes.shape({ + email: PropTypes.string.isRequired, + }), + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + betas: [], + task: {}, + personalDetails: {}, + reports: {}, + session: {}, +}; + +/** + * Get the assignee data + * + * @param {Object} details + * @returns {Object} + */ +function constructAssignee(details) { + const source = ReportUtils.getAvatar(lodashGet(details, 'avatar', ''), lodashGet(details, 'login', '')); + return { + icons: [{source, type: 'avatar', name: details.login}], + displayName: details.displayName, + subtitle: details.login, + }; +} + +/** + * Get the share destination data + * @param {Object} reportID + * @param {Object} reports + * @param {Object} personalDetails + * @returns {Object} + * */ +function constructShareDestination(reportID, reports, personalDetails) { + const report = lodashGet(reports, `report_${reportID}`, {}); + return { + icons: ReportUtils.getIcons(report, personalDetails), + displayName: ReportUtils.getReportName(report), + subtitle: ReportUtils.getChatRoomSubtitle(report), + }; +} + +const NewTaskPage = (props) => { + const [assignee, setAssignee] = React.useState({}); + const [shareDestination, setShareDestination] = React.useState({}); + const [submitError, setSubmitError] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(props.translate('newTaskPage.confirmError')); + const [parentReport, setParentReport] = React.useState({}); + + useEffect(() => { + setSubmitError(false); + + // If we have an assignee, we want to set the assignee data + // If there's an issue with the assignee chosen, we want to notify the user + if (props.task.assignee) { + const assigneeDetails = lodashGet(props.personalDetails, props.task.assignee); + if (!assigneeDetails) { + setSubmitError(true); + return setErrorMessage(props.translate('newTaskPage.assigneeError')); + } + const displayDetails = constructAssignee(assigneeDetails); + setAssignee(displayDetails); + } + + // We only set the parentReportID if we are creating a task from a report + // this allows us to go ahead and set that report as the share destination + // and disable the share destination selector + if (props.task.parentReportID) { + TaskUtils.setShareDestinationValue(props.task.parentReportID); + } + + // If we have a share destination, we want to set the parent report and + // the share destination data + if (props.task.shareDestination) { + setParentReport(lodashGet(props.reports, `report_${props.task.shareDestination}`, {})); + const displayDetails = constructShareDestination( + props.task.shareDestination, + props.reports, + props.personalDetails, + ); + setShareDestination(displayDetails); + } + }, [props]); + + // On submit, we want to call the createTask function and wait to validate + // the response + function onSubmit() { + if (!props.task.title || !props.task.shareDestination) { + setSubmitError(true); + return; + } + + TaskUtils.createTaskAndNavigate( + props.session.email, + parentReport.reportID, + props.task.title, + props.task.description, + props.task.assignee, + ); + } + + if (!Permissions.canUseTasks(props.betas)) { + Navigation.dismissModal(); + return null; + } + + return ( + + Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> + + + + Navigation.navigate(ROUTES.NEW_TASK_TITLE)} + label="newTaskPage.title" + /> + + + Navigation.navigate(ROUTES.NEW_TASK_DESCRIPTION)} + label="newTaskPage.description" + /> + + + Navigation.navigate(ROUTES.NEW_TASK_ASSIGNEE)} + label="newTaskPage.assignee" + /> + + + Navigation.navigate(ROUTES.NEW_TASK_SHARE_DESTINATION)} + label="newTaskPage.shareSomewhere" + isShareDestination + disabled={Boolean(props.task.parentReportID)} + /> + + + onSubmit()} + enabledWhenOffline + buttonText={props.translate('newTaskPage.confirmTask')} + containerStyles={[styles.mh0, styles.mt5, styles.flex1]} + /> + + + ); +}; + +NewTaskPage.displayName = 'NewTaskPage'; +NewTaskPage.propTypes = propTypes; +NewTaskPage.defaultProps = defaultProps; + +export default compose( + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + task: { + key: ONYXKEYS.TASK, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, + session: { + key: ONYXKEYS.SESSION, + }, + }), + withLocalize, +)(NewTaskPage); diff --git a/src/pages/tasks/NewTaskTitlePage.js b/src/pages/tasks/NewTaskTitlePage.js new file mode 100644 index 000000000000..239a13704c26 --- /dev/null +++ b/src/pages/tasks/NewTaskTitlePage.js @@ -0,0 +1,109 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import styles from '../../styles/styles'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as ErrorUtils from '../../libs/ErrorUtils'; +import Form from '../../components/Form'; +import TextInput from '../../components/TextInput'; +import Permissions from '../../libs/Permissions'; +import ROUTES from '../../ROUTES'; +import * as TaskUtils from '../../libs/actions/Task'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** Grab the Share title of the Task */ + task: PropTypes.shape({ + /** Title of the Task */ + title: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + betas: [], + task: { + title: '', + }, +}; + +const NewTaskTitlePage = (props) => { + /** + * @param {Object} values - form input values passed by the Form component + * @returns {Boolean} + */ + function validate(values) { + const errors = {}; + + if (!values.taskTitle) { + // We error if the user doesn't enter a task name + ErrorUtils.addErrorMessage(errors, 'taskTitle', props.translate('newTaskPage.pleaseEnterTaskName')); + } + + return errors; + } + + // On submit, we want to call the assignTask function and wait to validate + // the response + function onSubmit(values) { + TaskUtils.setTitleValue(values.taskTitle); + Navigation.navigate(ROUTES.getNewTaskRoute()); + } + + if (!Permissions.canUseTasks(props.betas)) { + Navigation.dismissModal(); + return null; + } + return ( + + Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> +
validate(values)} + onSubmit={values => onSubmit(values)} + enabledWhenOffline + > + + + +
+
+ ); +}; + +NewTaskTitlePage.displayName = 'NewTaskTitlePage'; +NewTaskTitlePage.propTypes = propTypes; +NewTaskTitlePage.defaultProps = defaultProps; + +export default compose( + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + task: { + key: ONYXKEYS.TASK, + }, + }), + withLocalize, +)(NewTaskTitlePage); diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js new file mode 100644 index 000000000000..6a6a10db2398 --- /dev/null +++ b/src/pages/tasks/TaskAssigneeSelectorModal.js @@ -0,0 +1,207 @@ +/* eslint-disable es/no-optional-chaining */ +import React, { + useState, useEffect, useCallback, useMemo, +} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import OptionsSelector from '../../components/OptionsSelector'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import ONYXKEYS from '../../ONYXKEYS'; +import styles from '../../styles/styles'; +import Navigation from '../../libs/Navigation/Navigation'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import Timing from '../../libs/actions/Timing'; +import CONST from '../../CONST'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import personalDetailsPropType from '../personalDetailsPropType'; +import reportPropTypes from '../reportPropTypes'; +import Performance from '../../libs/Performance'; +import * as TaskUtils from '../../libs/actions/Task'; + +const propTypes = { + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All of the personal details for everyone */ + personalDetails: personalDetailsPropType, + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), + + /** Grab the Share destination of the Task */ + task: PropTypes.shape({ + /** Share destination of the Task */ + shareDestination: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + betas: [], + personalDetails: {}, + reports: {}, + task: { + shareDestination: '', + }, +}; + +const TaskAssigneeSelectorModal = (props) => { + const [searchValue, setSearchValue] = useState(''); + const [headerMessage, setHeaderMessage] = useState(''); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(null); + + useEffect(() => { + const results = OptionsListUtils.getNewChatOptions( + props.reports, + props.personalDetails, + props.betas, + '', + [], + CONST.EXPENSIFY_EMAILS, + false, + ); + + setFilteredRecentReports(results.recentReports); + setFilteredPersonalDetails(results.personalDetails); + setFilteredUserToInvite(results.userToInvite); + }, [props]); + + const updateOptions = useCallback(() => { + const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions( + props.reports, + props.personalDetails, + props.betas, + searchValue.trim(), + [], + CONST.EXPENSIFY_EMAILS, + false, + ); + + setHeaderMessage(OptionsListUtils.getHeaderMessage(recentReports?.length + personalDetails?.length !== 0, Boolean(userToInvite), searchValue)); + + setFilteredUserToInvite(userToInvite); + setFilteredRecentReports(recentReports); + setFilteredPersonalDetails(personalDetails); + }, [props, searchValue]); + + useEffect(() => { + Timing.start(CONST.TIMING.SEARCH_RENDER); + Performance.markStart(CONST.TIMING.SEARCH_RENDER); + + updateOptions(); + + return () => { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); + }; + }, [updateOptions]); + + const onChangeText = (newSearchTerm = '') => { + setSearchValue(newSearchTerm); + updateOptions(); + }; + + const sections = useMemo(() => { + const sectionsList = []; + let indexOffset = 0; + + sectionsList.push({ + title: props.translate('common.recents'), + data: filteredRecentReports, + shouldShow: filteredRecentReports?.length > 0, + indexOffset, + }); + indexOffset += filteredRecentReports?.length; + + sectionsList.push({ + title: props.translate('common.contacts'), + data: filteredPersonalDetails, + shouldShow: filteredPersonalDetails?.length > 0, + indexOffset, + }); + indexOffset += filteredRecentReports?.length; + + if (filteredUserToInvite) { + sectionsList.push({ + data: [filteredUserToInvite], + shouldShow: filteredUserToInvite?.length > 0, + indexOffset, + }); + } + + return sectionsList; + }, [filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, props]); + + const selectReport = (option) => { + if (!option) { + return; + } + + if (option.alternateText) { + // Clear out the state value, set the assignee and navigate back to the NewTaskPage + setSearchValue(''); + TaskUtils.setAssigneeValue(option.alternateText, props.task.shareDestination); + Navigation.goBack(); + } + }; + + return ( + + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + <> + Navigation.goBack()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> + + { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); + }} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} + /> + + + )} + + ); +}; + +TaskAssigneeSelectorModal.displayName = 'TaskAssigneeSelectorModal'; +TaskAssigneeSelectorModal.propTypes = propTypes; +TaskAssigneeSelectorModal.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + task: { + key: ONYXKEYS.TASK, + }, + }), +)(TaskAssigneeSelectorModal); diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js new file mode 100644 index 000000000000..cd155d915956 --- /dev/null +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js @@ -0,0 +1,192 @@ +/* eslint-disable es/no-optional-chaining */ +import React, {useState, useEffect, useCallback} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import OptionsSelector from '../../components/OptionsSelector'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import ONYXKEYS from '../../ONYXKEYS'; +import styles from '../../styles/styles'; +import Navigation from '../../libs/Navigation/Navigation'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import Timing from '../../libs/actions/Timing'; +import CONST from '../../CONST'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import personalDetailsPropType from '../personalDetailsPropType'; +import reportPropTypes from '../reportPropTypes'; +import Performance from '../../libs/Performance'; +import * as TaskUtils from '../../libs/actions/Task'; + +const propTypes = { + /* Onyx Props */ + + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All of the personal details for everyone */ + personalDetails: personalDetailsPropType, + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + betas: [], + personalDetails: {}, + reports: {}, +}; + +const TaskShareDestinationSelectorModal = (props) => { + const [searchValue, setSearchValue] = useState(''); + const [headerMessage, setHeaderMessage] = useState(''); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(null); + + useEffect(() => { + const results = OptionsListUtils.getShareDestinationOptions(props.reports, props.personalDetails, props.betas, '', [], CONST.EXPENSIFY_EMAILS, true); + + setFilteredUserToInvite(results.userToInvite); + setFilteredRecentReports(results.recentReports); + setFilteredPersonalDetails(results.personalDetails); + }, [props]); + + const updateOptions = useCallback(() => { + const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getShareDestinationOptions( + props.reports, + props.personalDetails, + props.betas, + searchValue.trim(), + [], + CONST.EXPENSIFY_EMAILS, + true, + ); + + setHeaderMessage(OptionsListUtils.getHeaderMessage(recentReports?.length + personalDetails?.length !== 0, Boolean(userToInvite), searchValue)); + + setFilteredUserToInvite(userToInvite); + setFilteredRecentReports(recentReports); + setFilteredPersonalDetails(personalDetails); + }, [props, searchValue]); + + useEffect(() => { + Timing.start(CONST.TIMING.SEARCH_RENDER); + Performance.markStart(CONST.TIMING.SEARCH_RENDER); + + updateOptions(); + + return () => { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); + }; + }, [updateOptions]); + + const onChangeText = (newSearchTerm = '') => { + setSearchValue(newSearchTerm); + updateOptions(); + }; + + const getSections = () => { + const sections = []; + let indexOffset = 0; + + if (filteredRecentReports?.length > 0) { + sections.push({ + data: filteredRecentReports, + shouldShow: true, + indexOffset, + }); + indexOffset += filteredRecentReports?.length; + } + + if (filteredPersonalDetails?.length > 0) { + sections.push({ + data: filteredPersonalDetails, + shouldShow: true, + indexOffset, + }); + indexOffset += filteredRecentReports?.length; + } + + if (filteredUserToInvite) { + sections.push({ + data: [filteredUserToInvite], + shouldShow: true, + indexOffset, + }); + } + + return sections; + }; + + const selectReport = (option) => { + if (!option) { + return; + } + + if (option.reportID) { + // Clear out the state value, set the assignee and navigate back to the NewTaskPage + setSearchValue(''); + TaskUtils.setShareDestinationValue(option.reportID); + Navigation.goBack(); + } + }; + + const sections = getSections(); + return ( + + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + <> + Navigation.goBack()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> + + { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); + }} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} + /> + + + )} + + ); +}; + +TaskShareDestinationSelectorModal.displayName = 'TaskShareDestinationSelectorModal'; +TaskShareDestinationSelectorModal.propTypes = propTypes; +TaskShareDestinationSelectorModal.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + }), +)(TaskShareDestinationSelectorModal); diff --git a/src/styles/styles.js b/src/styles/styles.js index 7d10688d0061..e6a24c610595 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1674,6 +1674,15 @@ const styles = { backgroundColor: themeColors.transparent, }, + taskSelectorLink: { + alignSelf: 'center', + height: 42, + width: '100%', + padding: 6, + margin: 3, + backgroundColor: themeColors.transparent, + }, + chatItemAttachmentPlaceholder: { backgroundColor: themeColors.sidebar, borderColor: themeColors.border, diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 8a71c44e8825..1f39d30154fb 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -190,6 +190,22 @@ describe('OptionsListUtils', () => { }, }; + const REPORTS_WITH_WORKSPACE_ROOMS = { + ...REPORTS, + 14: { + lastReadTime: '2021-01-14 11:25:39.302', + lastVisibleActionCreated: '2022-11-22 03:26:02.022', + isPinned: false, + reportID: 14, + participants: ['reedrichards@expensify.com', 'brucebanner@expensify.com', 'peterparker@expensify.com'], + reportName: '', + oldPolicyName: 'Avengers Room', + isArchivedRoom: false, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + isOwnPolicyExpenseChat: true, + }, + }; + const PERSONAL_DETAILS_WITH_CONCIERGE = { ...PERSONAL_DETAILS, @@ -611,6 +627,44 @@ describe('OptionsListUtils', () => { ); }); + it('getShareDestinationsOptions()', () => { + // When we pass an empty search value + let results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], ''); + + // Then we should expect 10 recent reports to show because we're grabbing DM chats and group chats + expect(results.recentReports.length).toBe(10); + + // When we pass a search value that doesn't match the group chat name + results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'mutants'); + + // Then we should expect no recent reports to show + expect(results.recentReports.length).toBe(0); + + // When we pass a search value that matches the group chat name + results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'Iron Man, Mr. Fantastic'); + + // Then we should expect the group chat to show along with the contacts matching the search + expect(results.recentReports.length).toBe(4); + + // When we also have a policy to return rooms in the results + results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], ''); + + // Then we should expect the DMS, the group chats and the workspace room to show + expect(results.recentReports.length).toBe(11); + + // When we search for a workspace room + results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Avengers Room'); + + // Then we should expect only the workspace room to show + expect(results.recentReports.length).toBe(1); + + // When we search for a workspace room that doesn't exist + results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Mutants Lair'); + + // Then we should expect no results to show + expect(results.recentReports.length).toBe(0); + }); + it('getMemberInviteOptions()', () => { // When we only pass personal details let results = OptionsListUtils.getMemberInviteOptions(PERSONAL_DETAILS, [], '');