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()}
+ />
+
+
+ );
+};
+
+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()}
/>
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()}
+ />
+
+
+ );
+};
+
+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, [], '');