diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index 528a0a11498a..e267769dc457 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -283,14 +283,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -325,11 +325,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -359,7 +359,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index f042dbb38a91..dd2aef38e1ee 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -395,14 +395,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -437,11 +437,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -471,7 +471,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 8e10f8b1d8b6..82092be7e0eb 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -362,14 +362,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -404,11 +404,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -438,7 +438,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js index 4441348a3c36..1752ae62f86c 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js @@ -40,8 +40,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -94,7 +97,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBody( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -105,6 +108,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -119,7 +124,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index 154dacbdc3c3..9c9a42709af0 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -49,8 +49,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -103,7 +106,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBody( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -114,6 +117,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -128,7 +133,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; @@ -434,14 +439,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -476,11 +481,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -510,7 +515,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index ea56ff5f4ebd..e4f7634bd849 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -321,14 +321,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -363,11 +363,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -397,7 +397,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index f272929d536a..f941c9524856 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -377,14 +377,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -419,11 +419,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -453,7 +453,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index b8d7d821d64e..f4168af28802 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -329,14 +329,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -371,11 +371,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -405,7 +405,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/getReleaseBody/index.js b/.github/actions/javascript/getReleaseBody/index.js index cc1321ce5cd5..547aafe23038 100644 --- a/.github/actions/javascript/getReleaseBody/index.js +++ b/.github/actions/javascript/getReleaseBody/index.js @@ -329,14 +329,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -371,11 +371,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -405,7 +405,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/isStagingDeployLocked/index.js b/.github/actions/javascript/isStagingDeployLocked/index.js index 8124c5795a5a..4938b5bb7745 100644 --- a/.github/actions/javascript/isStagingDeployLocked/index.js +++ b/.github/actions/javascript/isStagingDeployLocked/index.js @@ -313,14 +313,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -355,11 +355,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -389,7 +389,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 36cd0aaefe4a..2e6ab7e018dd 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -478,14 +478,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -520,11 +520,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -554,7 +554,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 329e0d3aad5d..9dd23d68ca0a 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -388,14 +388,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -430,11 +430,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -464,7 +464,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 6a5f89badb5e..42196053f63f 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -283,14 +283,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -325,11 +325,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -359,7 +359,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 322b529b89bf..22335b36bd2b 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -283,14 +283,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -325,11 +325,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -359,7 +359,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index ba188d3a2b86..239f20c9d258 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -283,14 +283,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -325,11 +325,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -359,7 +359,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/.github/libs/GithubUtils.js b/.github/libs/GithubUtils.js index 0cd407c78153..e988167850ec 100644 --- a/.github/libs/GithubUtils.js +++ b/.github/libs/GithubUtils.js @@ -250,14 +250,14 @@ class GithubUtils { .then((data) => { // The format of this map is following: // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' // } const internalQAPRMap = _.reduce( _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), (map, pr) => { // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); + map[pr.html_url] = pr.merged_by.login; return map; }, {}, @@ -292,11 +292,11 @@ class GithubUtils { if (!_.isEmpty(internalQAPRMap)) { console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; + issueBody += ` - ${mergerMention}`; issueBody += '\r\n'; }); issueBody += '\r\n\r\n'; @@ -326,7 +326,9 @@ class GithubUtils { issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const issueAssignees = _.values(internalQAPRMap); + const issue = {issueBody, issueAssignees}; + return issue; }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } diff --git a/android/app/build.gradle b/android/app/build.gradle index 73e8e61111dc..812f1008777d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001044800 - versionName "1.4.48-0" + versionCode 1001044903 + versionName "1.4.49-3" } flavorDimensions "default" diff --git a/android/build.gradle b/android/build.gradle index 7b5dd81e5bf1..10600480d8bb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -44,6 +44,9 @@ allprojects { force "com.facebook.react:react-native:" + REACT_NATIVE_VERSION force "com.facebook.react:hermes-engine:" + REACT_NATIVE_VERSION + //Fix Investigate App Crash MainActivity.onCreate #35655 + force "com.facebook.soloader:soloader:0.10.4+" + eachDependency { dependency -> if (dependency.requested.group == 'org.bouncycastle') { println dependency.requested.module diff --git a/assets/images/document-slash.svg b/assets/images/document-slash.svg new file mode 100644 index 000000000000..25a4c96038b4 --- /dev/null +++ b/assets/images/document-slash.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"> + <path d="M18 13.9861V7.5C18 7.22386 17.7761 7 17.5 7H11.5C11.2239 7 11 6.77614 11 6.5V0.5C11 0.223858 10.7761 0 10.5 0H4C3.52923 0 3.09644 0.162656 2.75478 0.434835L18 13.9861Z"/> + <path d="M2 7.79165V18C2 19.1046 2.89543 20 4 20H15.7344L2 7.79165Z"/> + <path d="M13 0L18 5H13.5C13.2239 5 13 4.77614 13 4.5V0Z"/> + <path d="M1.66437 2.2526C1.25159 1.88568 0.619519 1.92286 0.252601 2.33565C-0.114317 2.74843 -0.0771359 3.3805 0.335647 3.74742L18.3356 19.7474C18.7484 20.1143 19.3805 20.0772 19.7474 19.6644C20.1143 19.2516 20.0772 18.6195 19.6644 18.2526L1.66437 2.2526Z"/> +</svg> diff --git a/assets/images/simple-illustrations/simple-illustration__car-ice.svg b/assets/images/simple-illustrations/simple-illustration__car-ice.svg new file mode 100644 index 000000000000..ba2b79bca6aa --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__car-ice.svg @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 68 68" style="enable-background:new 0 0 68 68;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#4ED7DE;} + .st1{fill:#4BA6A6;} + .st2{fill:#28736D;} + .st3{fill:#FFED8F;} + .st4{fill:none;stroke:#002140;stroke-width:0.8571;stroke-linecap:round;stroke-linejoin:round;} +</style> + <path class="st0" d="M49.708,27.953c0,0-3.324-9.358-5.152-9.753c-1.828-0.395-11.801-2.123-20.018-0.156 + c0,0-3.065,2.99-4.107,5.504c-1.042,2.517-1.564,4.484-1.564,4.484s-3.52,2.123-4.565,5.504c-1.042,3.381-0.391,11.247-0.391,11.247 + s0.846,0.786,2.414,1.259c1.568,0.473,6.066,1.102,8.15,1.102s23.151-0.157,23.151-0.157l6.389-1.572c0,0,0.914-1.337,0.978-2.279 + c0.064-0.942-0.26-8.494-0.26-8.494s-0.846-2.752-1.5-3.46c-0.654-0.708-3.52-3.225-3.52-3.225L49.708,27.953z"/> + <path class="st1" d="M18.931,27.563l-1.109-2.752l-2.478,0.708l-0.391,1.415l1.305,0.786L18.931,27.563z"/> + <path class="st1" d="M50.035,27.717l4.107-0.473v-1.021l-0.782-1.102l-2.542-0.235L50.035,27.717z"/> + <path class="st2" d="M23.496,46.751l21.974,0.078l-2.087-1.888H24.865L23.496,46.751z"/> + <path class="st2" d="M15.475,46.045l0.914,3.616l1.301,0.868l4.37-0.316l0.914-1.259l0.523-2.201L15.475,46.045z"/> + <path class="st2" d="M45.601,47.304l0.519,2.436l1.177,0.868l4.889-0.078l1.042-1.415l0.327-3.147l-8.086,0.864"/> + <path class="st3" d="M20.754,34.404l-2.997-2.044l-1.045,0.942l-0.26,2.283l0.978,1.02l3.129,1.259l1.305-1.099L20.754,34.404z"/> + <path class="st3" d="M51.6,32.514l-0.914-0.469l-2.87,2.279l-0.846,2.044l0.523,0.864l1.693-0.156l3.001-1.65L51.6,32.514z"/> + <path class="st3" d="M22.319,27.324l0.914,0.629h22.76l0.391-1.024l-3.001-6.294c0,0-5.479-1.181-9.977-1.024 + c-4.498,0.156-8.477,1.102-8.477,1.102l-2.61,6.606V27.324z"/> + <path class="st4" d="M44.265,18.161c-4.27-1.376-13.728-1.849-20.05,0"/> + <path class="st4" d="M24.21,18.16c0,0-2.837,1.653-5.085,9.675c0,0-5.38,2.83-5.38,8.612c0,0-0.391,3.893,19.759,3.893 + s21.423-3.776,21.423-3.776c0-5.782-5.38-8.612-5.38-8.612c-2.251-8.021-5.085-9.675-5.085-9.675"/> + <path class="st4" d="M13.747,36.447v6.923c0,1.241,0.757,2.29,1.774,2.468l6.23,1.074c0.572,0.1,1.152-0.096,1.6-0.537l0.878-0.868 + c0.37-0.363,0.832-0.562,1.309-0.562h9.241"/> + <path class="st4" d="M54.906,36.434v6.923c0,1.241-0.757,2.29-1.774,2.468l-6.229,1.074c-0.573,0.099-1.152-0.096-1.6-0.537 + l-0.878-0.868c-0.37-0.363-0.832-0.562-1.308-0.562h-9.241"/> + <path class="st4" d="M22.433,46.891l23.787-0.014"/> + <path class="st4" d="M15.521,45.838l0.412,2.667c0.178,1.148,1.01,1.984,1.981,1.984h3.264c0.946,0,1.764-0.793,1.966-1.906 + l0.288-1.575"/> + <path class="st4" d="M53.505,46.016l-0.412,2.667c-0.178,1.148-1.01,1.984-1.981,1.984h-3.264c-0.946,0-1.764-0.793-1.966-1.906 + l-0.288-1.575"/> + <path class="st4" d="M18.88,27.836l-0.544-2.35c-0.103-0.441-0.462-0.722-0.839-0.651l-1.888,0.356 + c-0.245,0.046-0.455,0.235-0.562,0.508l-0.242,0.622c-0.228,0.579,0.107,1.252,0.636,1.287l3.438,0.231"/> + <path class="st4" d="M49.946,27.719l0.544-2.35c0.103-0.441,0.462-0.722,0.839-0.651l1.888,0.356 + c0.245,0.046,0.455,0.235,0.562,0.508l0.242,0.622c0.228,0.58-0.107,1.252-0.636,1.287l-3.438,0.231"/> + <path class="st4" d="M16.833,33.047l-0.501,1.728c-0.185,0.633,0.092,1.323,0.615,1.547l2.869,1.223 + c0.128,0.053,0.263,0.085,0.398,0.085l0.533,0.011c0.853,0.018,1.337-1.173,0.8-1.973l-0.764-1.145 + c-0.06-0.092-0.135-0.174-0.217-0.242l-2.219-1.835c-0.548-0.452-1.298-0.156-1.518,0.597L16.833,33.047z"/> + <path class="st4" d="M51.678,32.811l0.501,1.728c0.185,0.633-0.092,1.323-0.615,1.547l-2.869,1.223 + c-0.128,0.053-0.263,0.085-0.398,0.085l-0.533,0.011c-0.853,0.018-1.337-1.173-0.8-1.973l0.764-1.145 + c0.06-0.093,0.135-0.174,0.217-0.242l2.219-1.835c0.548-0.452,1.298-0.157,1.518,0.597L51.678,32.811z"/> + <path class="st4" d="M45.53,28.111H23.197c-0.576,0-0.956-0.672-0.715-1.259l2.4-5.856c0.103-0.249,0.295-0.43,0.533-0.491 + c1.589-0.42,8.118-1.852,17.668,0.028c0.245,0.05,0.459,0.228,0.569,0.48l2.581,5.817c0.263,0.59-0.117,1.284-0.704,1.284V28.111z" + /> + <path class="st4" d="M25.843,35.819c-0.686,0-3.065-0.395-3.324-3.069c-0.26-2.674-0.103-5.643-0.103-5.643"/> + <path class="st4" d="M42.71,35.778c0.722,0,3.221-0.395,3.499-3.069c0.277-2.674,0.107-5.643,0.107-5.643"/> + <path class="st4" d="M42.711,35.779c0,0-16.185,0.039-16.868,0.039"/> +</svg> diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 935fb8a0083f..602cb1de71ba 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.4.48</string> + <string>1.4.49</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleURLTypes</key> @@ -40,7 +40,7 @@ </dict> </array> <key>CFBundleVersion</key> - <string>1.4.48.0</string> + <string>1.4.49.3</string> <key>ITSAppUsesNonExemptEncryption</key> <false/> <key>LSApplicationQueriesSchemes</key> diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 9d42e39387b4..61ac30356852 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ <key>CFBundlePackageType</key> <string>BNDL</string> <key>CFBundleShortVersionString</key> - <string>1.4.48</string> + <string>1.4.49</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>1.4.48.0</string> + <string>1.4.49.3</string> </dict> </plist> diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index dd33f988f7c4..8b48846258a3 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundleShortVersionString</key> - <string>1.4.48</string> + <string>1.4.49</string> <key>CFBundleVersion</key> - <string>1.4.48.0</string> + <string>1.4.49.3</string> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> diff --git a/package-lock.json b/package-lock.json index 8f3a17edec48..8efb036099a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.48-0", + "version": "1.4.49-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.48-0", + "version": "1.4.49-3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 46ee7afd6548..480806d7d111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.48-0", + "version": "1.4.49-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index 70fecab70c39..645746c38a7d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1417,6 +1417,11 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + DISTANCE_RATES_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, }, CUSTOM_UNITS: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2ed9fbc3666e..db0068365760 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -566,6 +566,10 @@ const ROUTES = { route: 'workspace/:policyID/members/:accountID/role-selection', getRoute: (policyID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`workspace/${policyID}/members/${accountID}/role-selection`, backTo), }, + WORKSPACE_DISTANCE_RATES: { + route: 'workspace/:policyID/distance-rates', + getRoute: (policyID: string) => `workspace/${policyID}/distance-rates` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { @@ -573,6 +577,10 @@ const ROUTES = { getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo), }, PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational', + TRANSACTION_RECEIPT: { + route: 'r/:reportID/transaction/:transactionID/receipt', + getRoute: (reportID: string, transactionID: string) => `r/${reportID}/transaction/${transactionID}/receipt` as const, + }, } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6fc61aec61a0..8546f543b77a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -229,6 +229,7 @@ const SCREENS = { CATEGORIES_SETTINGS: 'Categories_Settings', MEMBER_DETAILS: 'Workspace_Member_Details', MEMBER_DETAILS_ROLE_SELECTION: 'Workspace_Member_Details_Role_Selection', + DISTANCE_RATES: 'Distance_Rates', }, EDIT_REQUEST: { @@ -276,6 +277,7 @@ const SCREENS = { GET_ASSISTANCE: 'GetAssistance', REFERRAL_DETAILS: 'Referral_Details', KEYBOARD_SHORTCUTS: 'KeyboardShortcuts', + TRANSACTION_RECEIPT: 'TransactionReceipt', } as const; type Screen = DeepValueOf<typeof SCREENS>; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index eed40d75387e..2f80af7f572a 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -288,7 +288,7 @@ function AttachmentModal({ const deleteAndCloseModal = useCallback(() => { IOU.detachReceipt(transaction?.transactionID ?? ''); setIsDeleteReceiptConfirmModalVisible(false); - Navigation.dismissModal(report?.reportID); + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '')); }, [transaction, report]); const isValidFile = useCallback((fileObject: FileObject) => { diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 61d3409c65ab..5f426f77b731 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -32,6 +32,7 @@ function ButtonWithDropdownMenu<IValueType>({ options, onOptionSelected, enterKeyEventListenerPriority = 0, + wrapperStyle, }: ButtonWithDropdownMenuProps<IValueType>) { const theme = useTheme(); const styles = useThemeStyles(); @@ -66,7 +67,7 @@ function ButtonWithDropdownMenu<IValueType>({ }, [windowWidth, windowHeight, isMenuVisible, anchorAlignment.vertical]); return ( - <View> + <View style={wrapperStyle}> {shouldAlwaysShowDropdownMenu || options.length > 1 ? ( <View style={[styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, style]}> <Button diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 9975c10c13c3..fcf1baaa6aed 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -10,6 +10,8 @@ type PaymentType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE | typeof CONST.IOU. type WorkspaceMemberBulkActionType = DeepValueOf<typeof CONST.POLICY.MEMBERS_BULK_ACTION_TYPES>; +type WorkspaceDistanceRatesBulkActionType = DeepValueOf<typeof CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES>; + type DropdownOption<TValueType> = { value: TValueType; text: string; @@ -66,6 +68,9 @@ type ButtonWithDropdownMenuProps<TValueType> = { /** Whether the dropdown menu should be shown even if it has only one option */ shouldAlwaysShowDropdownMenu?: boolean; + + /** Additional style to add to the wrapper */ + wrapperStyle?: StyleProp<ViewStyle>; }; -export type {PaymentType, WorkspaceMemberBulkActionType, DropdownOption, ButtonWithDropdownMenuProps}; +export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps}; diff --git a/src/components/FlatList/index.android.tsx b/src/components/FlatList/index.android.tsx index 863930203863..1246367d29e8 100644 --- a/src/components/FlatList/index.android.tsx +++ b/src/components/FlatList/index.android.tsx @@ -1,7 +1,7 @@ import {useFocusEffect} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useContext} from 'react'; -import type {FlatListProps} from 'react-native'; +import type {FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {FlatList} from 'react-native'; import {ActionListContext} from '@pages/home/ReportScreenContext'; @@ -22,6 +22,9 @@ function CustomFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) } }, [scrollPosition?.offset, ref]); + // eslint-disable-next-line react-hooks/exhaustive-deps + const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => setScrollPosition({offset: event.nativeEvent.contentOffset.y}), []); + useFocusEffect( useCallback(() => { onScreenFocus(); @@ -32,10 +35,8 @@ function CustomFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) <FlatList<T> // eslint-disable-next-line react/jsx-props-no-spreading {...props} - onScroll={(event) => props.onScroll?.(event)} - onMomentumScrollEnd={(event) => { - setScrollPosition({offset: event.nativeEvent.contentOffset.y}); - }} + onScroll={props.onScroll} + onMomentumScrollEnd={onMomentumScrollEnd} ref={ref} /> ); diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 83afbad8636b..c3ffb500080b 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -1,9 +1,9 @@ import type {ReactNode} from 'react'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import type {Action} from '@hooks/useSingleExecution'; import type {StepCounterParams} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; -import type {PersonalDetails, Policy, Report} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -101,9 +101,6 @@ type HeaderWithBackButtonProps = Partial<ChildrenProps> & { /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */ policy?: OnyxEntry<Policy>; - /** Policies, if we're showing the details for a report and need participant details for AvatarWithDisplay */ - personalDetails?: OnyxCollection<PersonalDetails>; - /** Single execution function to prevent concurrent navigation actions */ singleExecution?: <T extends unknown[]>(action: Action<T>) => Action<T>; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index d9f46203a703..3f1cde92a583 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -42,6 +42,7 @@ import Concierge from '@assets/images/concierge.svg'; import Connect from '@assets/images/connect.svg'; import Copy from '@assets/images/copy.svg'; import CreditCard from '@assets/images/creditcard.svg'; +import DocumentSlash from '@assets/images/document-slash.svg'; import Document from '@assets/images/document.svg'; import DotIndicatorUnfilled from '@assets/images/dot-indicator-unfilled.svg'; import DotIndicator from '@assets/images/dot-indicator.svg'; @@ -194,6 +195,7 @@ export { CreditCard, DeletedRoomAvatar, Document, + DocumentSlash, DomainRoomAvatar, DotIndicator, DotIndicatorUnfilled, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 7f60ad3867c8..58cefb1877ce 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -33,6 +33,7 @@ import Approval from '@assets/images/simple-illustrations/simple-illustration__a import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg'; import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg'; import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg'; +import CarIce from '@assets/images/simple-illustrations/simple-illustration__car-ice.svg'; import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg'; import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; @@ -148,4 +149,5 @@ export { ThreeLeggedLaptopWoman, House, Tag, + CarIce, }; diff --git a/src/components/InlineSystemMessage.tsx b/src/components/InlineSystemMessage.tsx index bef4c21289d5..4cf7aeb4433e 100644 --- a/src/components/InlineSystemMessage.tsx +++ b/src/components/InlineSystemMessage.tsx @@ -33,3 +33,5 @@ function InlineSystemMessage({message = ''}: InlineSystemMessageProps) { InlineSystemMessage.displayName = 'InlineSystemMessage'; export default InlineSystemMessage; + +export type {InlineSystemMessageProps}; diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx index e28400505280..0549e19c2eb4 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx @@ -6,6 +6,11 @@ import FlatList from '@components/FlatList'; const WINDOW_SIZE = 15; const AUTOSCROLL_TO_TOP_THRESHOLD = 128; +const maintainVisibleContentPosition = { + minIndexForVisible: 0, + autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD, +}; + function BaseInvertedFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<FlatList>) { return ( <FlatList @@ -13,10 +18,7 @@ function BaseInvertedFlatList<T>(props: FlatListProps<T>, ref: ForwardedRef<Flat {...props} ref={ref} windowSize={WINDOW_SIZE} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: AUTOSCROLL_TO_TOP_THRESHOLD, - }} + maintainVisibleContentPosition={maintainVisibleContentPosition} inverted /> ); diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index f5545f402b14..93eac30d5477 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -1,9 +1,8 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; -import React, {memo, useCallback} from 'react'; +import React, {memo, useCallback, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import withCurrentReportID from '@components/withCurrentReportID'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -28,7 +27,6 @@ function LHNOptionsList({ preferredLocale = CONST.LOCALES.DEFAULT, personalDetails = {}, transactions = {}, - currentReportID = '', draftComments = {}, transactionViolations = {}, onFirstItemRendered = () => {}, @@ -86,7 +84,7 @@ function LHNOptionsList({ lastReportActionTransaction={lastReportActionTransaction} receiptTransactions={transactions} viewMode={optionMode} - isFocused={!shouldDisableFocusOptions && reportID === currentReportID} + isFocused={!shouldDisableFocusOptions} onSelectRow={onSelectRow} preferredLocale={preferredLocale} comment={itemComment} @@ -98,7 +96,6 @@ function LHNOptionsList({ ); }, [ - currentReportID, draftComments, onSelectRow, optionMode, @@ -116,6 +113,8 @@ function LHNOptionsList({ ], ); + const extraData = useMemo(() => [reportActions, reports, policy, personalDetails], [reportActions, reports, policy, personalDetails]); + return ( <View style={style ?? styles.flex1}> <FlashList @@ -127,7 +126,7 @@ function LHNOptionsList({ keyExtractor={keyExtractor} renderItem={renderItem} estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight} - extraData={[currentReportID]} + extraData={extraData} showsVerticalScrollIndicator={false} /> </View> @@ -136,33 +135,31 @@ function LHNOptionsList({ LHNOptionsList.displayName = 'LHNOptionsList'; -export default withCurrentReportID( - withOnyx<LHNOptionsListProps, LHNOptionsListOnyxProps>({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - reportActions: { - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - }, - policy: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - transactions: { - key: ONYXKEYS.COLLECTION.TRANSACTION, - }, - draftComments: { - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - }, - transactionViolations: { - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - }, - })(memo(LHNOptionsList)), -); +export default withOnyx<LHNOptionsListProps, LHNOptionsListOnyxProps>({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + reportActions: { + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + }, + policy: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + transactions: { + key: ONYXKEYS.COLLECTION.TRANSACTION, + }, + draftComments: { + key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, + }, + transactionViolations: { + key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, + }, +})(memo(LHNOptionsList)); export type {LHNOptionsListProps}; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index a3394190d0c1..9b22b50b64fe 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -1,5 +1,6 @@ import {deepEqual} from 'fast-equals'; import React, {useEffect, useMemo, useRef} from 'react'; +import useCurrentReportID from '@hooks/useCurrentReportID'; import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import * as Report from '@userActions/Report'; @@ -32,6 +33,8 @@ function OptionRowLHNData({ ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; + const currentReportIDValue = useCurrentReportID(); + const isReportFocused = isFocused && currentReportIDValue?.currentReportID === reportID; const optionItemRef = useRef<OptionData>(); @@ -85,7 +88,7 @@ function OptionRowLHNData({ <OptionRowLHN // eslint-disable-next-line react/jsx-props-no-spreading {...propsToForward} - isFocused={isFocused} + isFocused={isReportFocused} optionItem={optionItem} /> ); diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index c122ab018392..4ca30358f9b1 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -3,7 +3,6 @@ import type {RefObject} from 'react'; import type {LayoutChangeEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import type {CurrentReportIDContextValue} from '@components/withCurrentReportID'; import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; import type {Locale, PersonalDetailsList, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -64,7 +63,7 @@ type CustomLHNOptionsListProps = { reportIDsWithErrors: Record<string, OnyxCommon.Errors>; }; -type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps; +type LHNOptionsListProps = CustomLHNOptionsListProps & LHNOptionsListOnyxProps; type OptionRowLHNDataProps = { /** Whether row should be focused */ diff --git a/src/components/LocalePicker.tsx b/src/components/LocalePicker.tsx index 3a2d9a0fd7b9..b6e59643e499 100644 --- a/src/components/LocalePicker.tsx +++ b/src/components/LocalePicker.tsx @@ -4,14 +4,18 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import AccountUtils from '@libs/AccountUtils'; import * as App from '@userActions/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Locale} from '@src/types/onyx'; +import type {Account, Locale} from '@src/types/onyx'; import Picker from './Picker'; import type {PickerSize} from './Picker/types'; type LocalePickerOnyxProps = { + /** The details about the account that the user is signing in with */ + account: OnyxEntry<Account>; + /** Indicates which locale the user currently has selected */ preferredLocale: OnyxEntry<Locale>; }; @@ -21,7 +25,7 @@ type LocalePickerProps = LocalePickerOnyxProps & { size?: PickerSize; }; -function LocalePicker({preferredLocale = CONST.LOCALES.DEFAULT, size = 'normal'}: LocalePickerProps) { +function LocalePicker({account, preferredLocale = CONST.LOCALES.DEFAULT, size = 'normal'}: LocalePickerProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -31,6 +35,7 @@ function LocalePicker({preferredLocale = CONST.LOCALES.DEFAULT, size = 'normal'} keyForList: language, isSelected: preferredLocale === language, })); + const shouldDisablePicker = AccountUtils.isValidateCodeFormSubmitting(account); return ( <Picker @@ -42,7 +47,10 @@ function LocalePicker({preferredLocale = CONST.LOCALES.DEFAULT, size = 'normal'} App.setLocale(locale); }} + isDisabled={shouldDisablePicker} items={localesToLanguages} + shouldAllowDisabledStyle={false} + shouldShowOnlyTextWhenDisabled={false} size={size} value={preferredLocale} containerStyles={size === 'small' ? styles.pickerContainerSmall : {}} @@ -54,6 +62,9 @@ function LocalePicker({preferredLocale = CONST.LOCALES.DEFAULT, size = 'normal'} LocalePicker.displayName = 'LocalePicker'; export default withOnyx<LocalePickerProps, LocalePickerOnyxProps>({ + account: { + key: ONYXKEYS.ACCOUNT, + }, preferredLocale: { key: ONYXKEYS.NVP_PREFERRED_LOCALE, }, diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 93febc4fd3c0..deff56a534ee 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -430,4 +430,4 @@ function MagicCodeInput( MagicCodeInput.displayName = 'MagicCodeInput'; export default forwardRef(MagicCodeInput); -export type {AutoCompleteVariant, MagicCodeInputHandle}; +export type {AutoCompleteVariant, MagicCodeInputHandle, MagicCodeInputProps}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 102f85ea49b9..2520520fd467 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -8,7 +8,6 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -21,7 +20,6 @@ import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; -import {usePersonalDetails} from './OnyxProvider'; import SettlementButton from './SettlementButton'; type PaymentType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE>; @@ -46,20 +44,15 @@ type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & { }; function MoneyReportHeader({session, policy, chatReport, nextStep, report: moneyRequestReport}: MoneyReportHeaderProps) { - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); - const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport); const policyType = policy?.type; - const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(moneyRequestReport, policy); - const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport); - const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && session?.accountID === moneyRequestReport.managerID; const isPayer = ReportUtils.isPayer(session, moneyRequestReport); - const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); + const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); const cancelPayment = useCallback(() => { @@ -70,25 +63,15 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money setIsConfirmModalVisible(false); }, [moneyRequestReport, chatReport]); - const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); - const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); - const shouldShowPayButton = useMemo( - () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableSpend !== 0 && !ReportUtils.isArchivedRoom(chatReport) && !isAutoReimbursable, - [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableSpend, chatReport, isAutoReimbursable], - ); - const shouldShowApproveButton = useMemo(() => { - if (!isPaidGroupPolicy) { - return false; - } - if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { - return false; - } - return isManager && !isDraft && !isApproved && !isSettled; - }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy]); + const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]); + + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(moneyRequestReport, chatReport, policy), [moneyRequestReport, chatReport, policy]); + const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; + const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0; const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; - const shouldShowNextStep = isFromPaidPolicy && !!nextStep?.message?.length; + const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length; const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport.currency); @@ -117,7 +100,6 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money shouldShowPinButton={false} report={moneyRequestReport} policy={policy} - personalDetails={personalDetails} shouldShowBackButton={isSmallScreenWidth} onBackButtonPress={() => Navigation.goBack(undefined, false, true)} // Shows border if no buttons or next steps are showing below the header diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 7f9ab3fe0dc9..338796cd856e 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -21,7 +21,6 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import HoldBanner from './HoldBanner'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; -import {usePersonalDetails} from './OnyxProvider'; import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu'; type MoneyRequestHeaderOnyxProps = { @@ -54,7 +53,6 @@ type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & { }; function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, shownHoldUseExplanation = false, policy}: MoneyRequestHeaderProps) { - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); @@ -167,7 +165,6 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, ownerAccountID: parentReport?.ownerAccountID, }} policy={policy} - personalDetails={personalDetails} shouldShowBackButton={isSmallScreenWidth} onBackButtonPress={() => Navigation.goBack(undefined, false, true)} /> diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index c86d3b71c1d9..03f1e0f496a8 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -33,7 +33,9 @@ function BasePicker<TPickerValue>( containerStyles, placeholder = {}, size = 'normal', + shouldAllowDisabledStyle = true, shouldFocusPicker = false, + shouldShowOnlyTextWhenDisabled = true, onBlur = () => {}, additionalPickerEvents = () => {}, }: BasePickerProps<TPickerValue>, @@ -155,7 +157,7 @@ function BasePicker<TPickerValue>( const hasError = !!errorText; - if (isDisabled) { + if (isDisabled && shouldShowOnlyTextWhenDisabled) { return ( <View> {!!label && ( @@ -176,14 +178,20 @@ function BasePicker<TPickerValue>( <> <View ref={root} - style={[styles.pickerContainer, isDisabled && styles.inputDisabled, containerStyles, isHighlighted && styles.borderColorFocus, hasError && styles.borderColorDanger]} + style={[ + styles.pickerContainer, + isDisabled && shouldAllowDisabledStyle && styles.inputDisabled, + containerStyles, + isHighlighted && styles.borderColorFocus, + hasError && styles.borderColorDanger, + ]} > {label && <Text style={[styles.pickerLabel, styles.textLabelSupporting, styles.pointerEventsNone]}>{label}</Text>} <RNPickerSelect onValueChange={onValueChange} // We add a text color to prevent white text on white background dropdown items on Windows items={items.map((item) => ({...item, color: itemColor}))} - style={size === 'normal' ? styles.picker(isDisabled, backgroundColor) : styles.pickerSmall(backgroundColor)} + style={size === 'normal' ? styles.picker(isDisabled, backgroundColor) : styles.pickerSmall(isDisabled, backgroundColor)} useNativeAndroidPickerStyle={false} placeholder={pickerPlaceholder} value={value} diff --git a/src/components/Picker/types.ts b/src/components/Picker/types.ts index a12f4cbe683a..3f7c0282d35a 100644 --- a/src/components/Picker/types.ts +++ b/src/components/Picker/types.ts @@ -70,9 +70,15 @@ type BasePickerProps<TPickerValue> = { /** The ID used to uniquely identify the input in a Form */ inputID?: string; + /** Show disabled style when disabled */ + shouldAllowDisabledStyle?: boolean; + /** Saves a draft of the input value when used in a form */ shouldSaveDraft?: boolean; + /** Show only picker's label and value when disabled */ + shouldShowOnlyTextWhenDisabled?: boolean; + /** A callback method that is called when the value changes and it receives the selected value as an argument */ onInputChange: (value: TPickerValue, index?: number) => void; diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 1a740d51a2af..60dbfc07966a 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -68,107 +68,111 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont <View style={[StyleUtils.getReportWelcomeContainerStyle(isSmallScreenWidth, true)]}> <AnimatedEmptyStateBackground /> <View style={[StyleUtils.getReportWelcomeTopMarginStyle(isSmallScreenWidth, true)]}> - {ReportUtils.reportFieldsEnabled(report) && - sortedPolicyReportFields.map((reportField) => { - const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); - const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; - const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); - - return ( - <OfflineWithFeedback - pendingAction={report.pendingFields?.[reportField.fieldID]} - errors={report.errorFields?.[reportField.fieldID]} - errorRowStyles={styles.ph5} - key={`menuItem-${reportField.fieldID}`} - > - <MenuItemWithTopDescription - description={Str.UCFirst(reportField.name)} - title={fieldValue} - onPress={() => Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} - shouldShowRightIcon - disabled={isFieldDisabled} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - shouldGreyOutWhenDisabled={false} - numberOfLinesTitle={0} - interactive - shouldStackHorizontally={false} - onSecondaryInteraction={() => {}} - hoverAndPressStyle={false} - titleWithTooltips={[]} - /> - </OfflineWithFeedback> - ); - })} - <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv2]}> - <View style={[styles.flex1, styles.justifyContentCenter]}> - <Text - style={[styles.textLabelSupporting]} - numberOfLines={1} - > - {translate('common.total')} - </Text> - </View> - <View style={[styles.flexRow, styles.justifyContentCenter]}> - {isSettled && ( - <View style={[styles.defaultCheckmarkWrapper, styles.mh2]}> - <Icon - src={Expensicons.Checkmark} - fill={theme.success} - /> - </View> - )} - <Text - numberOfLines={1} - style={[styles.taskTitleMenuItem, styles.alignSelfCenter, !isTotalUpdated && styles.offlineFeedback.pending]} - > - {formattedTotalAmount} - </Text> - </View> - </View> - {Boolean(shouldShowBreakdown) && ( + {!ReportUtils.isClosedExpenseReportWithNoExpenses(report) && ( <> - <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv1]}> - <View style={[styles.flex1, styles.justifyContentCenter]}> - <Text - style={[styles.textLabelSupporting]} - numberOfLines={1} - > - {translate('cardTransactions.outOfPocket')} - </Text> - </View> - <View style={[styles.flexRow, styles.justifyContentCenter]}> - <Text - numberOfLines={1} - style={subAmountTextStyles} - > - {formattedOutOfPocketAmount} - </Text> - </View> - </View> - <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv1]}> + {ReportUtils.reportFieldsEnabled(report) && + sortedPolicyReportFields.map((reportField) => { + const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); + const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; + const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); + + return ( + <OfflineWithFeedback + pendingAction={report.pendingFields?.[reportField.fieldID]} + errors={report.errorFields?.[reportField.fieldID]} + errorRowStyles={styles.ph5} + key={`menuItem-${reportField.fieldID}`} + > + <MenuItemWithTopDescription + description={Str.UCFirst(reportField.name)} + title={fieldValue} + onPress={() => Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} + shouldShowRightIcon + disabled={isFieldDisabled} + wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} + shouldGreyOutWhenDisabled={false} + numberOfLinesTitle={0} + interactive + shouldStackHorizontally={false} + onSecondaryInteraction={() => {}} + hoverAndPressStyle={false} + titleWithTooltips={[]} + /> + </OfflineWithFeedback> + ); + })} + <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv2]}> <View style={[styles.flex1, styles.justifyContentCenter]}> <Text style={[styles.textLabelSupporting]} numberOfLines={1} > - {translate('cardTransactions.companySpend')} + {translate('common.total')} </Text> </View> <View style={[styles.flexRow, styles.justifyContentCenter]}> + {isSettled && ( + <View style={[styles.defaultCheckmarkWrapper, styles.mh2]}> + <Icon + src={Expensicons.Checkmark} + fill={theme.success} + /> + </View> + )} <Text numberOfLines={1} - style={subAmountTextStyles} + style={[styles.taskTitleMenuItem, styles.alignSelfCenter, !isTotalUpdated && styles.offlineFeedback.pending]} > - {formattedCompanySpendAmount} + {formattedTotalAmount} </Text> </View> </View> + {Boolean(shouldShowBreakdown) && ( + <> + <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv1]}> + <View style={[styles.flex1, styles.justifyContentCenter]}> + <Text + style={[styles.textLabelSupporting]} + numberOfLines={1} + > + {translate('cardTransactions.outOfPocket')} + </Text> + </View> + <View style={[styles.flexRow, styles.justifyContentCenter]}> + <Text + numberOfLines={1} + style={subAmountTextStyles} + > + {formattedOutOfPocketAmount} + </Text> + </View> + </View> + <View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv1]}> + <View style={[styles.flex1, styles.justifyContentCenter]}> + <Text + style={[styles.textLabelSupporting]} + numberOfLines={1} + > + {translate('cardTransactions.companySpend')} + </Text> + </View> + <View style={[styles.flexRow, styles.justifyContentCenter]}> + <Text + numberOfLines={1} + style={subAmountTextStyles} + > + {formattedCompanySpendAmount} + </Text> + </View> + </View> + </> + )} + <SpacerView + shouldShow={shouldShowHorizontalRule} + style={[shouldShowHorizontalRule && styles.reportHorizontalRule]} + /> </> )} - <SpacerView - shouldShow={shouldShowHorizontalRule} - style={[shouldShowHorizontalRule && styles.reportHorizontalRule]} - /> </View> </View> ); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 1d048640b30b..70c65d1d66ce 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -144,7 +144,10 @@ function MoneyRequestView({ const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true)); const {getViolationsForField} = useViolations(transactionViolations ?? []); - const hasViolations = useCallback((field: ViolationField): boolean => !!canUseViolations && getViolationsForField(field).length > 0, [canUseViolations, getViolationsForField]); + const hasViolations = useCallback( + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']): boolean => !!canUseViolations && getViolationsForField(field, data).length > 0, + [canUseViolations, getViolationsForField], + ); let amountDescription = `${translate('iou.amount')}`; @@ -197,7 +200,7 @@ function MoneyRequestView({ const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; const getErrorForField = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], shouldShowViolations = true) => { + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']) => { // Checks applied when creating a new money request // NOTE: receipt field can return multiple violations, so we need to handle it separately const fieldChecks: Partial<Record<ViolationField, {isError: boolean; translationPath: TranslationPaths}>> = { @@ -223,9 +226,8 @@ function MoneyRequestView({ } // Return violations if there are any - // At the moment, we only return violations for tags for workspaces with single-level tags - if (canUseViolations && shouldShowViolations && hasViolations(field)) { - const violations = getViolationsForField(field); + if (canUseViolations && hasViolations(field, data)) { + const violations = getViolationsForField(field, data); return ViolationsUtils.getViolationTranslation(violations[0], translate); } @@ -262,7 +264,6 @@ function MoneyRequestView({ filename={receiptURIs?.filename} transaction={transaction} enablePreviewModal - canEditReceipt={canEditReceipt} /> )} </View> @@ -396,8 +397,8 @@ function MoneyRequestView({ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, index, transaction?.transactionID ?? '', report.reportID), ) } - brickRoadIndicator={getErrorForField('tag', {}, policyTagLists.length === 1) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={getErrorForField('tag', {}, policyTagLists.length === 1)} + brickRoadIndicator={getErrorForField('tag', {tagListIndex: index, tagListName: name}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tag', {tagListIndex: index, tagListName: name})} /> </OfflineWithFeedback> ))} diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index d47604738fbc..f71f98998026 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -4,7 +4,6 @@ import type {ReactElement} from 'react'; import type {ImageSourcePropType, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import AttachmentModal from '@components/AttachmentModal'; import EReceiptThumbnail from '@components/EReceiptThumbnail'; import * as Expensicons from '@components/Icon/Expensicons'; import Image from '@components/Image'; @@ -14,10 +13,12 @@ import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import ThumbnailImage from '@components/ThumbnailImage'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type {Transaction} from '@src/types/onyx'; type ReportActionItemImageProps = { @@ -36,9 +37,6 @@ type ReportActionItemImageProps = { /** whether thumbnail is refer the local file or not */ isLocalFile?: boolean; - /** whether the receipt can be replaced */ - canEditReceipt?: boolean; - /** Filename of attachment */ filename?: string; @@ -52,16 +50,7 @@ type ReportActionItemImageProps = { * and optional preview modal as well. */ -function ReportActionItemImage({ - thumbnail, - image, - enablePreviewModal = false, - transaction, - canEditReceipt = false, - isLocalFile = false, - filename, - isSingleImage = true, -}: ReportActionItemImageProps) { +function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, transaction, isLocalFile = false, filename, isSingleImage = true}: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const attachmentModalSource = tryResolveUrlFromApiRoot(image ?? ''); @@ -118,26 +107,14 @@ function ReportActionItemImage({ return ( <ShowContextMenuContext.Consumer> {({report}) => ( - <AttachmentModal - source={attachmentModalSource} - isAuthTokenRequired={!isLocalFile} - report={report} - isReceiptAttachment - canEditReceipt={canEditReceipt} - allowDownload={!isEReceipt} - originalFileName={filename} + <PressableWithoutFocus + style={[styles.w100, styles.h100, styles.noOutline as ViewStyle]} + onPress={() => Navigation.navigate(ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID ?? '', transaction?.transactionID ?? ''))} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + accessibilityRole={CONST.ROLE.BUTTON} > - {({show}) => ( - <PressableWithoutFocus - style={[styles.w100, styles.h100, styles.noOutline as ViewStyle]} - onPress={show} - accessibilityRole={CONST.ROLE.BUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - > - {receiptImageComponent} - </PressableWithoutFocus> - )} - </AttachmentModal> + {receiptImageComponent} + </PressableWithoutFocus> )} </ShowContextMenuContext.Consumer> ); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 381302489699..743bfd8fff88 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -19,7 +19,6 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -30,7 +29,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportAction, Session, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import ReportActionItemImages from './ReportActionItemImages'; @@ -44,9 +43,6 @@ type ReportPreviewOnyxProps = { /** Active IOU Report for current report */ iouReport: OnyxEntry<Report>; - /** Session info for the currently logged in user. */ - session: OnyxEntry<Session>; - /** All the transactions, used to update ReportPreview label and status */ transactions: OnyxCollection<Transaction>; @@ -85,7 +81,6 @@ type ReportPreviewProps = ReportPreviewOnyxProps & { function ReportPreview({ iouReport, - session, policy, iouReportID, policyID, @@ -118,16 +113,13 @@ function ReportPreview({ ); const managerID = iouReport?.managerID ?? 0; - const isCurrentUserManager = managerID === session?.accountID; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); - const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(iouReport, policy); const iouSettled = ReportUtils.isSettled(iouReportID); - const iouCanceled = ReportUtils.isArchivedRoom(chatReport); const numberOfRequests = ReportActionUtils.getNumberOfMoneyRequests(action); const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); - const isDraftExpenseReport = isPolicyExpenseChat && ReportUtils.isDraftExpenseReport(iouReport); + const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); const isApproved = ReportUtils.isReportApproved(iouReport); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport); @@ -155,7 +147,7 @@ function ReportPreview({ pendingReceipts: numberOfPendingRequests, }); - const shouldShowSubmitButton = isDraftExpenseReport && reimbursableSpend !== 0; + const shouldShowSubmitButton = isOpenExpenseReport && reimbursableSpend !== 0; // The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on const isWaitingForSubmissionFromCurrentUser = useMemo( @@ -208,23 +200,10 @@ function ReportPreview({ const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); - const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport); - const isPayer = ReportUtils.isPayer(session, iouReport); - const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); - const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); - const shouldShowPayButton = useMemo( - () => isPayer && !isDraftExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable, - [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, isAutoReimbursable, iouReport], - ); - const shouldShowApproveButton = useMemo(() => { - if (!isPaidGroupPolicy) { - return false; - } - if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { - return false; - } - return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled; - }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy, iouSettled]); + const shouldShowPayButton = useMemo(() => IOU.canIOUBePaid(iouReport, chatReport, policy), [iouReport, chatReport, policy]); + + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, chatReport, policy), [iouReport, chatReport, policy]); + const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; /* @@ -353,9 +332,6 @@ export default withOnyx<ReportPreviewProps, ReportPreviewOnyxProps>({ iouReport: { key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, }, - session: { - key: ONYXKEYS.SESSION, - }, transactions: { key: ONYXKEYS.COLLECTION.TRANSACTION, }, diff --git a/src/components/VideoPlayer/utils.ts b/src/components/VideoPlayer/utils.ts index 57f5422d0ce5..c13af0f874d1 100644 --- a/src/components/VideoPlayer/utils.ts +++ b/src/components/VideoPlayer/utils.ts @@ -1,9 +1,11 @@ -import {format} from 'date-fns'; - -// Converts milliseconds to 'minutes:seconds' format +// Converts milliseconds to '[hours:]minutes:seconds' format const convertMillisecondsToTime = (milliseconds: number) => { - const date = new Date(milliseconds); - return format(date, 'mm:ss'); + const hours = Math.floor(milliseconds / 3600000); + const minutes = Math.floor((milliseconds / 60000) % 60); + const seconds = Math.floor((milliseconds / 1000) % 60) + .toFixed(0) + .padStart(2, '0'); + return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`; }; export default convertMillisecondsToTime; diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 29b2dcb86718..3df457f1205a 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -58,7 +58,34 @@ function useViolations(violations: TransactionViolation[]) { } return violationGroups ?? new Map(); }, [violations]); - const getViolationsForField = useCallback((field: ViolationField) => violationsByField.get(field) ?? [], [violationsByField]); + + const getViolationsForField = useCallback( + (field: ViolationField, data?: TransactionViolation['data']) => { + const currentViolations = violationsByField.get(field) ?? []; + + // someTagLevelsRequired has special logic becase data.errorIndexes is a bit unique in how it denotes the tag list that has the violation + // tagListIndex can be 0 so we compare with undefined + if (currentViolations[0]?.name === 'someTagLevelsRequired' && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { + return currentViolations + .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) + .map((violation) => ({ + ...violation, + data: { + ...violation.data, + tagName: data?.tagListName, + }, + })); + } + + // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on + if (currentViolations[0]?.name === 'tagOutOfPolicy' && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); + } + + return currentViolations; + }, + [violationsByField], + ); return { getViolationsForField, diff --git a/src/languages/en.ts b/src/languages/en.ts index afda15377fcc..5f0f1b0b57a8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1755,6 +1755,8 @@ export default { workspaceAvatar: 'Workspace avatar', mustBeOnlineToViewMembers: 'You must be online in order to view members of this workspace.', requested: 'Requested', + distanceRates: 'Distance rates', + selected: ({selectedNumber}) => `${selectedNumber} selected`, }, type: { free: 'Free', @@ -1816,7 +1818,6 @@ export default { makeMember: 'Make member', makeAdmin: 'Make admin', selectAll: 'Select all', - selected: ({selectedNumber}) => `${selectedNumber} selected`, error: { genericAdd: 'There was a problem adding this workspace member.', cannotRemove: 'You cannot remove yourself or the workspace owner.', @@ -1909,6 +1910,23 @@ export default { welcomeNote: ({workspaceName}: WelcomeNoteParams) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, }, + distanceRates: { + oopsNotSoFast: 'Oops! Not so fast...', + workspaceNeeds: 'A workspace needs at least one enabled distance rate.', + distance: 'Distance', + centrallyManage: 'Centrally manage rates, choose to track in miles or kilometers, and set a default category.', + rate: 'Rate', + addRate: 'Add rate', + deleteRate: 'Delete rate', + deleteRates: 'Delete rates', + enableRate: 'Enable rate', + disableRate: 'Disable rate', + disableRates: 'Disable rates', + enableRates: 'Enable rates', + status: 'Status', + enabled: 'Enabled', + disabled: 'Disabled', + }, editor: { descriptionInputLabel: 'Description', nameInputLabel: 'Name', @@ -2204,6 +2222,7 @@ export default { viewAttachment: 'View attachment', }, parentReportAction: { + deletedReport: '[Deleted report]', deletedMessage: '[Deleted message]', deletedRequest: '[Deleted request]', reversedTransaction: '[Reversed transaction]', @@ -2411,7 +2430,7 @@ export default { return ''; }, smartscanFailed: 'Receipt scanning failed. Enter details manually.', - someTagLevelsRequired: 'Missing tag', + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Missing ${tagName ?? 'Tag'}`, tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `${tagName ?? 'Tag'} no longer valid`, taxAmountChanged: 'Tax amount was modified', taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'Tax'} no longer valid`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 88f065682d14..b95beffa0ece 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1779,6 +1779,8 @@ export default { workspaceAvatar: 'Espacio de trabajo avatar', mustBeOnlineToViewMembers: 'Debes estar en línea para poder ver los miembros de este espacio de trabajo.', requested: 'Solicitado', + distanceRates: 'Tasas de distancia', + selected: ({selectedNumber}) => `${selectedNumber} seleccionados`, }, type: { free: 'Gratis', @@ -1840,7 +1842,6 @@ export default { makeMember: 'Hacer miembro', makeAdmin: 'Hacer administrador', selectAll: 'Seleccionar todo', - selected: ({selectedNumber}) => `${selectedNumber} seleccionados`, error: { genericAdd: 'Ha ocurrido un problema al añadir el miembro al espacio de trabajo.', cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo.', @@ -1934,6 +1935,23 @@ export default { welcomeNote: ({workspaceName}: WelcomeNoteParams) => `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, }, + distanceRates: { + oopsNotSoFast: 'Ups! No tan rápido...', + workspaceNeeds: 'Un espacio de trabajo necesita al menos una tasa de distancia activa.', + distance: 'Distancia', + centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', + rate: 'Tasa', + addRate: 'Agregar tasa', + deleteRate: 'Eliminar tasa', + deleteRates: 'Eliminar tasas', + enableRate: 'Activar tasa', + disableRate: 'Desactivar tasa', + disableRates: 'Desactivar tasas', + enableRates: 'Activar tasas', + status: 'Estado', + enabled: 'Activada', + disabled: 'Desactivada', + }, editor: { nameInputLabel: 'Nombre', descriptionInputLabel: 'Descripción', @@ -2692,6 +2710,7 @@ export default { viewAttachment: 'Ver archivo adjunto', }, parentReportAction: { + deletedReport: '[Informe eliminado]', deletedMessage: '[Mensaje eliminado]', deletedRequest: '[Solicitud eliminada]', reversedTransaction: '[Transacción anulada]', @@ -2903,7 +2922,7 @@ export default { return ''; }, smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', - someTagLevelsRequired: 'Falta etiqueta', + someTagLevelsRequired: ({tagName}: ViolationsTagOutOfPolicyParams) => `Falta ${tagName ?? 'Tag'}`, tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `La etiqueta ${tagName ? `${tagName} ` : ''}ya no es válida`, taxAmountChanged: 'El importe del impuesto fue modificado', taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName ?? 'El impuesto'} ya no es válido`, diff --git a/src/libs/API/parameters/OpenPolicyDistanceRatesPageParams.ts b/src/libs/API/parameters/OpenPolicyDistanceRatesPageParams.ts new file mode 100644 index 000000000000..6594e258fdb6 --- /dev/null +++ b/src/libs/API/parameters/OpenPolicyDistanceRatesPageParams.ts @@ -0,0 +1,5 @@ +type OpenPolicyDistanceRatesPageParams = { + policyID: string; +}; + +export default OpenPolicyDistanceRatesPageParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f529032130bb..b56398f6c4ad 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -159,3 +159,4 @@ export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; export type {default as AcceptJoinRequestParams} from './AcceptJoinRequest'; export type {default as DeclineJoinRequestParams} from './DeclineJoinRequest'; export type {default as JoinPolicyInviteLinkParams} from './JoinPolicyInviteLink'; +export type {default as OpenPolicyDistanceRatesPageParams} from './OpenPolicyDistanceRatesPageParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 1b41ced4f1d7..d2aa1c84a9a1 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -351,6 +351,7 @@ const READ_COMMANDS = { OPEN_POLICY_CATEGORIES_PAGE: 'OpenPolicyCategoriesPage', OPEN_WORKSPACE_INVITE_PAGE: 'OpenWorkspaceInvitePage', OPEN_DRAFT_WORKSPACE_REQUEST: 'OpenDraftWorkspaceRequest', + OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage', } as const; type ReadCommand = ValueOf<typeof READ_COMMANDS>; @@ -386,6 +387,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_POLICY_CATEGORIES_PAGE]: Parameters.OpenPolicyCategoriesPageParams; [READ_COMMANDS.OPEN_WORKSPACE_INVITE_PAGE]: Parameters.OpenWorkspaceInvitePageParams; [READ_COMMANDS.OPEN_DRAFT_WORKSPACE_REQUEST]: Parameters.OpenDraftWorkspaceRequestParams; + [READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/AccountUtils.ts b/src/libs/AccountUtils.ts new file mode 100644 index 000000000000..d903584e15b4 --- /dev/null +++ b/src/libs/AccountUtils.ts @@ -0,0 +1,8 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import type {Account} from '@src/types/onyx'; + +const isValidateCodeFormSubmitting = (account: OnyxEntry<Account>) => + !!account?.isLoading && account.loadingForm === (account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM); + +export default {isValidateCodeFormSubmitting}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 6f5dcdf9cda9..8c582b8ab259 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -63,6 +63,7 @@ const loadConciergePage = () => require('../../../pages/ConciergePage').default const loadProfileAvatar = () => require('../../../pages/settings/Profile/ProfileAvatar').default as React.ComponentType; const loadWorkspaceAvatar = () => require('../../../pages/workspace/WorkspaceAvatar').default as React.ComponentType; const loadReportAvatar = () => require('../../../pages/ReportAvatar').default as React.ComponentType; +const loadReceiptView = () => require('../../../pages/TransactionReceiptPage').default as React.ComponentType; const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default as React.ComponentType; let timezone: Timezone | null; @@ -363,8 +364,18 @@ function AuthScreens({session, lastOpenedPublicRoomID, isUsingMemoryOnlyKeys = f headerShown: false, presentation: 'transparentModal', }} + listeners={modalScreenListeners} getComponent={loadWorkspaceJoinUser} /> + <RootStack.Screen + name={SCREENS.TRANSACTION_RECEIPT} + options={{ + headerShown: false, + presentation: 'transparentModal', + }} + getComponent={loadReceiptView} + listeners={modalScreenListeners} + /> </RootStack.Navigator> </View> ); diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 976699e31716..28e3bc8f5a88 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -25,6 +25,7 @@ const workspaceSettingsScreens = { [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../../pages/workspace/WorkspaceMembersPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../../pages/workspace/categories/WorkspaceCategoriesPage').default as React.ComponentType, [SCREENS.WORKSPACE.TAGS]: () => require('../../../../../pages/workspace/tags/WorkspaceTagsPage').default as React.ComponentType, + [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default as React.ComponentType, } satisfies Screens; function BaseCentralPaneNavigator() { diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts index f4316009b70b..6641b2c88f1a 100755 --- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts @@ -14,6 +14,7 @@ const TAB_TO_CENTRAL_PANE_MAPPING: Record<BottomTabName, CentralPaneName[]> = { SCREENS.WORKSPACE.TRAVEL, SCREENS.WORKSPACE.MEMBERS, SCREENS.WORKSPACE.CATEGORIES, + SCREENS.WORKSPACE.DISTANCE_RATES, ], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 8a24dc177a80..fc3ad1668cd4 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -22,6 +22,7 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.PROFILE_AVATAR]: ROUTES.PROFILE_AVATAR.route, [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route, [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route, + [SCREENS.TRANSACTION_RECEIPT]: ROUTES.TRANSACTION_RECEIPT.route, [SCREENS.WORKSPACE_JOIN_USER]: ROUTES.WORKSPACE_JOIN_USER.route, // Sidebar @@ -71,6 +72,9 @@ const config: LinkingOptions<RootStackParamList>['config'] = { [SCREENS.WORKSPACE.TAGS]: { path: ROUTES.WORKSPACE_TAGS.route, }, + [SCREENS.WORKSPACE.DISTANCE_RATES]: { + path: ROUTES.WORKSPACE_DISTANCE_RATES.route, + }, }, }, [SCREENS.NOT_FOUND]: '*', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index decb905ac52f..9f100d0e1efb 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -95,6 +95,9 @@ type CentralPaneNavigatorParamList = { policyID: string; categoryName: string; }; + [SCREENS.WORKSPACE.DISTANCE_RATES]: { + policyID: string; + }; }; type WorkspaceSwitcherNavigatorParamList = { @@ -592,6 +595,10 @@ type AuthScreensParamList = SharedScreensParamList & { [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams<RightModalNavigatorParamList>; [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: NavigatorScreenParams<FullScreenNavigatorParamList>; [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; + [SCREENS.TRANSACTION_RECEIPT]: { + reportID: string; + transactionID: string; + }; }; type RootStackParamList = PublicScreensParamList & AuthScreensParamList; diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index f03c34b1696e..0a5cfad2d146 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -73,7 +73,7 @@ function buildNextStep( const {policyID = '', ownerAccountID = -1, managerID = -1} = report; const policy = ReportUtils.getPolicy(policyID); - const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy; + const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApproval, autoReportingFrequency, autoReportingOffset} = policy; const isOwner = currentUserAccountID === ownerAccountID; const isManager = currentUserAccountID === managerID; const isSelfApproval = currentUserAccountID === submitsTo; @@ -164,7 +164,7 @@ function buildNextStep( } // Prevented self submitting - if ((isPreventSelfApprovalEnabled ?? preventSelfApprovalEnabled) && isSelfApproval) { + if ((isPreventSelfApprovalEnabled ?? preventSelfApproval) && isSelfApproval) { optimisticNextStep.message = [ { text: "Oops! Looks like you're submitting to ", diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index fd803a508b4a..3dd23752d5db 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -584,7 +584,7 @@ function getLastMessageTextForReport(report: OnyxEntry<Report>, lastActorDetails const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(report?.reportID, lastReportAction); lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); } else if (ReportActionUtils.isTaskAction(lastReportAction)) { - lastMessageTextFromReport = TaskUtils.getTaskReportActionMessage(lastReportAction).text; + lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text); } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); } else if (ReportActionUtils.isApprovedOrSubmittedReportAction(lastReportAction)) { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index f6534e075773..9f5b0ada4cb1 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -236,7 +236,7 @@ function isInstantSubmitEnabled(policy: OnyxEntry<Policy> | EmptyObject): boolea /** * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" */ -function isSubmitAndClose(policy: OnyxEntry<Policy>): boolean { +function isSubmitAndClose(policy: OnyxEntry<Policy> | EmptyObject): boolean { return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; } diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/pusher.ts index 3cb15c0f3fc3..48c5e5c1409f 100644 --- a/src/libs/Pusher/pusher.ts +++ b/src/libs/Pusher/pusher.ts @@ -1,5 +1,6 @@ import isObject from 'lodash/isObject'; import type {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption'; +import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import type {LiteralUnion, ValueOf} from 'type-fest'; import Log from '@libs/Log'; @@ -226,48 +227,50 @@ function subscribe<EventName extends PusherEventName>( onResubscribe = () => {}, ): Promise<void> { return new Promise((resolve, reject) => { - // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. - if (!socket) { - throw new Error(`[Pusher] instance not found. Pusher.subscribe() + InteractionManager.runAfterInteractions(() => { + // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. + if (!socket) { + throw new Error(`[Pusher] instance not found. Pusher.subscribe() most likely has been called before Pusher.init()`); - } + } - Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName}); - let channel = getChannel(channelName); - - if (!channel || !channel.subscribed) { - channel = socket.subscribe(channelName); - let isBound = false; - channel.bind('pusher:subscription_succeeded', () => { - // Check so that we do not bind another event with each reconnect attempt - if (!isBound) { - bindEventToChannel(channel, eventName, eventCallback); - resolve(); - isBound = true; - return; - } - - // When subscribing for the first time we register a success callback that can be - // called multiple times when the subscription succeeds again in the future - // e.g. as a result of Pusher disconnecting and reconnecting. This callback does - // not fire on the first subscription_succeeded event. - onResubscribe(); - }); - - channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => { - const {type, error, status} = data; - Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', { - channelName, - status, - type, - error, + Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName}); + let channel = getChannel(channelName); + + if (!channel || !channel.subscribed) { + channel = socket.subscribe(channelName); + let isBound = false; + channel.bind('pusher:subscription_succeeded', () => { + // Check so that we do not bind another event with each reconnect attempt + if (!isBound) { + bindEventToChannel(channel, eventName, eventCallback); + resolve(); + isBound = true; + return; + } + + // When subscribing for the first time we register a success callback that can be + // called multiple times when the subscription succeeds again in the future + // e.g. as a result of Pusher disconnecting and reconnecting. This callback does + // not fire on the first subscription_succeeded event. + onResubscribe(); }); - reject(error); - }); - } else { - bindEventToChannel(channel, eventName, eventCallback); - resolve(); - } + + channel.bind('pusher:subscription_error', (data: PusherSubscribtionErrorData = {}) => { + const {type, error, status} = data; + Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', { + channelName, + status, + type, + error, + }); + reject(error); + }); + } else { + bindEventToChannel(channel, eventName, eventCallback); + resolve(); + } + }); }); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c623cb4d167b..b828ad1f17e4 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -45,7 +45,7 @@ import type { ReimbursementDeQueuedMessage, } from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; -import type {NotificationPreference} from '@src/types/onyx/Report'; +import type {NotificationPreference, PendingChatMember} from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type {Receipt, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -706,7 +706,7 @@ function isReportApproved(reportOrID: OnyxEntry<Report> | string | EmptyObject): /** * Checks if the supplied report is an expense report in Open state and status. */ -function isDraftExpenseReport(report: OnyxEntry<Report> | EmptyObject): boolean { +function isOpenExpenseReport(report: OnyxEntry<Report> | EmptyObject): boolean { return isExpenseReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; } @@ -1076,6 +1076,20 @@ function findLastAccessedReport( return adminReport ?? sortedReports.at(-1) ?? null; } +/** + * Whether the provided report has expenses + */ +function hasExpenses(reportID?: string): boolean { + return !!Object.values(allTransactions ?? {}).find((transaction) => `${transaction?.reportID}` === `${reportID}`); +} + +/** + * Whether the provided report is a closed expense report with no expenses + */ +function isClosedExpenseReportWithNoExpenses(report: OnyxEntry<Report>): boolean { + return report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && isExpenseReport(report) && !hasExpenses(report.reportID); +} + /** * Whether the provided report is an archived room */ @@ -2131,7 +2145,7 @@ function getMoneyRequestReportName(report: OnyxEntry<Report>, policy: OnyxEntry< return Localize.translateLocal('iou.payerSpentAmount', {payer: payerOrApproverName, amount: formattedAmount}); } - if (isProcessingReport(report) || isDraftExpenseReport(report) || moneyRequestTotal === 0) { + if (isProcessingReport(report) || isOpenExpenseReport(report) || moneyRequestTotal === 0) { return Localize.translateLocal('iou.payerOwesAmount', {payer: payerOrApproverName, amount: formattedAmount}); } @@ -2212,7 +2226,7 @@ function canEditMoneyRequest(reportAction: OnyxEntry<ReportAction>): boolean { const isManager = currentUserAccountID === moneyRequestReport?.managerID; // Admin & managers can always edit coding fields such as tag, category, billable, etc. As long as the report has a state higher than OPEN. - if ((isAdmin || isManager) && !isDraftExpenseReport(moneyRequestReport)) { + if ((isAdmin || isManager) && !isOpenExpenseReport(moneyRequestReport)) { return true; } @@ -2620,6 +2634,10 @@ function getReportName(report: OnyxEntry<Report>, policy: OnyxEntry<Policy> = nu return parentReportActionMessage; } + if (isClosedExpenseReportWithNoExpenses(report)) { + return Localize.translateLocal('parentReportAction.deletedReport'); + } + if (isTaskReport(report) && isCanceledTaskReport(report, parentReportAction)) { return Localize.translateLocal('parentReportAction.deletedTask'); } @@ -2679,6 +2697,14 @@ function getChatRoomSubtitle(report: OnyxEntry<Report>): string | undefined { return getPolicyName(report); } +/** + * Get pending members for reports + */ +function getPendingChatMembers(accountIDs: number[], previousPendingChatMembers: PendingChatMember[], pendingAction: PendingAction): PendingChatMember[] { + const pendingChatMembers = accountIDs.map((accountID) => ({accountID: accountID.toString(), pendingAction})); + return [...previousPendingChatMembers, ...pendingChatMembers]; +} + /** * Gets the parent navigation subtitle for the report */ @@ -3811,16 +3837,19 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string): Op const announceReportActionData = { [announceCreatedAction.reportActionID]: announceCreatedAction, }; - - const adminsChatData = buildOptimisticChatReport( - [currentUserAccountID ?? -1], - CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, - CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, - policyID, - CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, - false, - policyName, - ); + const pendingChatMembers = getPendingChatMembers(currentUserAccountID ? [currentUserAccountID] : [], [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + const adminsChatData = { + ...buildOptimisticChatReport( + [currentUserAccountID ?? -1], + CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, + CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + policyID, + CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, + false, + policyName, + ), + pendingChatMembers, + }; const adminsChatReportID = adminsChatData.reportID; const adminsCreatedAction = buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); const adminsReportActionData = { @@ -5142,8 +5171,8 @@ function getAllAncestorReportActionIDs(report: Report | null | undefined): Ances return allAncestorIDs; } -function canBeAutoReimbursed(report: OnyxEntry<Report>, policy: OnyxEntry<Policy> = null): boolean { - if (!policy) { +function canBeAutoReimbursed(report: OnyxEntry<Report>, policy: OnyxEntry<Policy> | EmptyObject): boolean { + if (isEmptyObject(policy)) { return false; } type CurrencyType = (typeof CONST.DIRECT_REIMBURSEMENT_CURRENCIES)[number]; @@ -5192,6 +5221,7 @@ export { getPolicyName, getPolicyType, isArchivedRoom, + isClosedExpenseReportWithNoExpenses, isExpensifyOnlyParticipantInReport, canCreateTaskInReport, isPolicyExpenseChatAdmin, @@ -5336,7 +5366,7 @@ export { getIOUReportActionDisplayMessage, isWaitingForAssigneeToCompleteTask, isGroupChat, - isDraftExpenseReport, + isOpenExpenseReport, shouldUseFullTitleToDisplay, parseReportRouteParams, getReimbursementQueuedActionMessage, @@ -5369,6 +5399,7 @@ export { getAvailableReportFields, reportFieldsEnabled, getAllAncestorReportActionIDs, + getPendingChatMembers, canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 3aa4cb63df9a..71b3fd23a03c 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -164,8 +164,11 @@ function getOrderedReportIDs( if (isInDefaultMode) { nonArchivedReports.sort((a, b) => { const compareDates = a?.lastVisibleActionCreated && b?.lastVisibleActionCreated ? compareStringDates(b.lastVisibleActionCreated, a.lastVisibleActionCreated) : 0; + if (compareDates) { + return compareDates; + } const compareDisplayNames = a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0; - return compareDates || compareDisplayNames; + return compareDisplayNames; }); // For archived reports ensure that most recent reports are at the top by reversing the order archivedReports.sort((a, b) => (a?.lastVisibleActionCreated && b?.lastVisibleActionCreated ? compareStringDates(b.lastVisibleActionCreated, a.lastVisibleActionCreated) : 0)); @@ -328,7 +331,7 @@ function getOptionData({ const newName = lastAction?.originalMessage?.newName ?? ''; result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName}); } else if (ReportActionsUtils.isTaskAction(lastAction)) { - result.alternateText = TaskUtils.getTaskReportActionMessage(lastAction).text; + result.alternateText = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastAction).text); } else if ( lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 6153ea62cd0d..fe2e5af537a7 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -7,6 +7,106 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx'; +/** + * Calculates tag out of policy and missing tag violations for the given transaction + */ +function getTagViolationsForSingleLevelTags( + updatedTransaction: Transaction, + transactionViolations: TransactionViolation[], + policyRequiresTags: boolean, + policyTagList: PolicyTagList, +): TransactionViolation[] { + const policyTagKeys = Object.keys(policyTagList); + const policyTagListName = policyTagKeys[0]; + const policyTags = policyTagList[policyTagListName]?.tags; + const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY); + const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG); + const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false; + let newTransactionViolations = [...transactionViolations]; + + // Add 'tagOutOfPolicy' violation if tag is not in policy + if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { + newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: 'violation'}); + } + + // Remove 'tagOutOfPolicy' violation if tag is in policy + if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY}); + } + + // Remove 'missingTag' violation if tag is valid according to policy + if (hasMissingTagViolation && isTagInPolicy) { + newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_TAG}); + } + + // Add 'missingTag violation' if tag is required and not set + if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) { + newTransactionViolations.push({name: CONST.VIOLATIONS.MISSING_TAG, type: 'violation'}); + } + return newTransactionViolations; +} + +/** + * Calculates some tag levels required and missing tag violations for the given transaction + */ +function getTagViolationsForMultiLevelTags( + updatedTransaction: Transaction, + transactionViolations: TransactionViolation[], + policyRequiresTags: boolean, + policyTagList: PolicyTagList, +): TransactionViolation[] { + const policyTagKeys = Object.keys(policyTagList); + const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? []; + let newTransactionViolations = [...transactionViolations]; + newTransactionViolations = newTransactionViolations.filter( + (violation) => violation.name !== CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && violation.name !== CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + ); + + // We first get the errorIndexes for someTagLevelsRequired. If it's not empty, we puth SOME_TAG_LEVELS_REQUIRED in Onyx. + // Otherwise, we put TAG_OUT_OF_POLICY in Onyx (when applicable) + const errorIndexes = []; + for (let i = 0; i < policyTagKeys.length; i++) { + const isTagRequired = policyTagList[policyTagKeys[i]].required ?? true; + const isTagSelected = Boolean(selectedTags[i]); + if (isTagRequired && (!isTagSelected || (selectedTags.length === 1 && selectedTags[0] === ''))) { + errorIndexes.push(i); + } + } + if (errorIndexes.length !== 0) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED, + type: 'violation', + data: { + errorIndexes, + }, + }); + } else { + let hasInvalidTag = false; + for (let i = 0; i < policyTagKeys.length; i++) { + const selectedTag = selectedTags[i]; + const tags = policyTagList[policyTagKeys[i]].tags; + const isTagInPolicy = Object.values(tags).some((tag) => tag.name === selectedTag && Boolean(tag.enabled)); + if (!isTagInPolicy) { + newTransactionViolations.push({ + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + type: 'violation', + data: { + tagName: policyTagKeys[i], + }, + }); + hasInvalidTag = true; + break; + } + } + if (!hasInvalidTag) { + newTransactionViolations = reject(newTransactionViolations, { + name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + }); + } + } + return newTransactionViolations; +} + const ViolationsUtils = { /** * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction @@ -22,6 +122,7 @@ const ViolationsUtils = { ): OnyxUpdate { let newTransactionViolations = [...transactionViolations]; + // Calculate client-side category violations if (policyRequiresCategories) { const hasCategoryOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === 'categoryOutOfPolicy'); const hasMissingCategoryViolation = transactionViolations.some((violation) => violation.name === 'missingCategory'); @@ -49,36 +150,12 @@ const ViolationsUtils = { } } + // Calculate client-side tag violations if (policyRequiresTags) { - const policyTagKeys = Object.keys(policyTagList); - - // At the moment, we only return violations for tags for workspaces with single-level tags - if (policyTagKeys.length === 1) { - const policyTagListName = policyTagKeys[0]; - const policyTags = policyTagList[policyTagListName]?.tags; - const hasTagOutOfPolicyViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY); - const hasMissingTagViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.MISSING_TAG); - const isTagInPolicy = policyTags ? !!policyTags[updatedTransaction.tag ?? '']?.enabled : false; - - // Add 'tagOutOfPolicy' violation if tag is not in policy - if (!hasTagOutOfPolicyViolation && updatedTransaction.tag && !isTagInPolicy) { - newTransactionViolations.push({name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY, type: 'violation'}); - } - - // Remove 'tagOutOfPolicy' violation if tag is in policy - if (hasTagOutOfPolicyViolation && updatedTransaction.tag && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.TAG_OUT_OF_POLICY}); - } - - // Remove 'missingTag' violation if tag is valid according to policy - if (hasMissingTagViolation && isTagInPolicy) { - newTransactionViolations = reject(newTransactionViolations, {name: CONST.VIOLATIONS.MISSING_TAG}); - } - // Add 'missingTag violation' if tag is required and not set - if (!hasMissingTagViolation && !updatedTransaction.tag && policyRequiresTags) { - newTransactionViolations.push({name: CONST.VIOLATIONS.MISSING_TAG, type: 'violation'}); - } - } + newTransactionViolations = + Object.keys(policyTagList).length === 1 + ? getTagViolationsForSingleLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList) + : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList); } return { @@ -181,7 +258,7 @@ const ViolationsUtils = { case 'smartscanFailed': return translate('violations.smartscanFailed'); case 'someTagLevelsRequired': - return translate('violations.someTagLevelsRequired'); + return translate('violations.someTagLevelsRequired', {tagName}); case 'tagOutOfPolicy': return translate('violations.tagOutOfPolicy', {tagName}); case 'taxAmountChanged': diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index b2e70627b803..4b88bb7a77a8 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3679,10 +3679,73 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency: Report.notifyNewAction(params.chatReportID, managerID); } +function canIOUBePaid(iouReport: OnyxEntry<OnyxTypes.Report> | EmptyObject, chatReport: OnyxEntry<OnyxTypes.Report> | EmptyObject, policy: OnyxEntry<OnyxTypes.Policy> | EmptyObject) { + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); + const iouCanceled = ReportUtils.isArchivedRoom(chatReport); + + if (isEmptyObject(iouReport)) { + return false; + } + + const isPayer = ReportUtils.isPayer( + { + email: currentUserEmail, + accountID: userAccountID, + }, + iouReport, + ); + + const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + const iouSettled = ReportUtils.isSettled(iouReport?.reportID); + + const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); + const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(iouReport, policy); + return isPayer && !isOpenExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable; +} + +function canApproveIOU(iouReport: OnyxEntry<OnyxTypes.Report> | EmptyObject, chatReport: OnyxEntry<OnyxTypes.Report> | EmptyObject, policy: OnyxEntry<OnyxTypes.Policy> | EmptyObject) { + if (isEmptyObject(chatReport)) { + return false; + } + const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport); + if (!isPaidGroupPolicy) { + return false; + } + + const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); + if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { + return false; + } + + const managerID = iouReport?.managerID ?? 0; + const isCurrentUserManager = managerID === userAccountID; + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); + + const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + const isApproved = ReportUtils.isReportApproved(iouReport); + const iouSettled = ReportUtils.isSettled(iouReport?.reportID); + + return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled; +} + +function hasIOUToApproveOrPay(chatReport: OnyxEntry<OnyxTypes.Report> | EmptyObject, excludedIOUReportID: string): boolean { + const chatReportActions = ReportActionsUtils.getAllReportActions(chatReport?.reportID ?? ''); + + return Object.values(chatReportActions).some((action) => { + const iouReport = ReportUtils.getReport(action.childReportID ?? ''); + const policy = ReportUtils.getPolicy(iouReport?.policyID); + + const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, chatReport, policy); + return action.childReportID?.toString() !== excludedIOUReportID && action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && shouldShowSettlementButton; + }); +} + function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID); const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.APPROVED); + const chatReport = ReportUtils.getReport(expenseReport.chatReportID); const optimisticReportActionsData: OnyxUpdate = { onyxMethod: Onyx.METHOD.MERGE, @@ -3705,12 +3768,21 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { statusNum: CONST.REPORT.STATUS_NUM.APPROVED, }, }; + + const optimisticChatReportData: OnyxUpdate = { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.chatReportID}`, + value: { + hasOutstandingChildRequest: hasIOUToApproveOrPay(chatReport, expenseReport?.reportID ?? ''), + }, + }; + const optimisticNextStepData: OnyxUpdate = { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, value: optimisticNextStep, }; - const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData]; + const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData, optimisticChatReportData]; const successData: OnyxUpdate[] = [ { @@ -3734,6 +3806,13 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.chatReportID}`, + value: { + hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + }, + }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`, @@ -4333,4 +4412,6 @@ export { cancelPayment, navigateToStartStepIfScanFileCannotBeRead, savePreferredPaymentMethod, + canIOUBePaid, + canApproveIOU, }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 1e88520bfa87..0c5df5a85d57 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -17,6 +17,7 @@ import type { DeleteWorkspaceParams, OpenDraftWorkspaceRequestParams, OpenPolicyCategoriesPageParams, + OpenPolicyDistanceRatesPageParams, OpenWorkspaceInvitePageParams, OpenWorkspaceMembersPageParams, OpenWorkspaceParams, @@ -57,7 +58,7 @@ import type { ReportAction, Transaction, } from '@src/types/onyx'; -import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {OriginalMessageJoinPolicyChangeLog} from '@src/types/onyx/OriginalMessage'; import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -66,6 +67,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; type AnnounceRoomMembersOnyxData = { onyxOptimisticData: OnyxUpdate[]; + onyxSuccessData: OnyxUpdate[]; onyxFailureData: OnyxUpdate[]; }; @@ -373,6 +375,7 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] const announceRoomMembers: AnnounceRoomMembersOnyxData = { onyxOptimisticData: [], onyxFailureData: [], + onyxSuccessData: [], }; if (!announceReport) { @@ -382,6 +385,7 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] if (announceReport?.participantAccountIDs) { // Everyone in special policy rooms is visible const participantAccountIDs = [...announceReport.participantAccountIDs, ...accountIDs]; + const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); announceRoomMembers.onyxOptimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -389,6 +393,7 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] value: { participantAccountIDs, visibleChatMemberAccountIDs: participantAccountIDs, + pendingChatMembers, }, }); } @@ -399,6 +404,14 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] value: { participantAccountIDs: announceReport?.participantAccountIDs, visibleChatMemberAccountIDs: announceReport?.visibleChatMemberAccountIDs, + pendingChatMembers: announceReport?.pendingChatMembers ?? null, + }, + }); + announceRoomMembers.onyxSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, + value: { + pendingChatMembers: announceReport?.pendingChatMembers ?? null, }, }); return announceRoomMembers; @@ -572,6 +585,7 @@ function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: numbe const announceRoomMembers: AnnounceRoomMembersOnyxData = { onyxOptimisticData: [], onyxFailureData: [], + onyxSuccessData: [], }; if (!announceReport) { @@ -580,12 +594,15 @@ function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: numbe if (announceReport?.participantAccountIDs) { const remainUsers = announceReport.participantAccountIDs.filter((e) => !accountIDs.includes(e)); + const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, announceReport?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + announceRoomMembers.onyxOptimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, value: { participantAccountIDs: [...remainUsers], visibleChatMemberAccountIDs: [...remainUsers], + pendingChatMembers, }, }); @@ -595,6 +612,14 @@ function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: numbe value: { participantAccountIDs: announceReport.participantAccountIDs, visibleChatMemberAccountIDs: announceReport.visibleChatMemberAccountIDs, + pendingChatMembers: announceReport?.pendingChatMembers ?? null, + }, + }); + announceRoomMembers.onyxSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + pendingChatMembers: announceReport?.pendingChatMembers ?? null, }, }); } @@ -638,6 +663,26 @@ function removeMembers(accountIDs: number[], policyID: string) { ...announceRoomMembers.onyxOptimisticData, ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: membersListKey, + value: successMembersState, + }, + ...announceRoomMembers.onyxSuccessData, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: membersListKey, + value: failureMembersState, + }, + ...announceRoomMembers.onyxFailureData, + ]; + + const pendingChatMembers = ReportUtils.getPendingChatMembers(accountIDs, [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + workspaceChats.forEach((report) => { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -647,6 +692,21 @@ function removeMembers(accountIDs: number[], policyID: string) { stateNum: CONST.REPORT.STATE_NUM.APPROVED, oldPolicyName: policy.name, hasDraft: false, + pendingChatMembers, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, + value: { + pendingChatMembers: null, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, + value: { + pendingChatMembers: null, }, }); }); @@ -683,23 +743,7 @@ function removeMembers(accountIDs: number[], policyID: string) { } } - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: membersListKey, - value: successMembersState, - }, - ]; - const filteredWorkspaceChats = workspaceChats.filter((report): report is Report => report !== null); - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: membersListKey, - value: failureMembersState, - }, - ...announceRoomMembers.onyxFailureData, - ]; filteredWorkspaceChats.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => { failureData.push({ @@ -750,7 +794,7 @@ function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newR onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, value: { - ...memberRoles.reduce((member: Record<number, {role: string; pendingAction: string | null}>, current) => { + ...memberRoles.reduce((member: Record<number, {role: string; pendingAction: PendingAction}>, current) => { // eslint-disable-next-line no-param-reassign member[current.accountID] = {role: current?.role, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}; return member; @@ -765,7 +809,7 @@ function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newR onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, value: { - ...memberRoles.reduce((member: Record<number, {role: string; pendingAction: string | null}>, current) => { + ...memberRoles.reduce((member: Record<number, {role: string; pendingAction: PendingAction}>, current) => { // eslint-disable-next-line no-param-reassign member[current.accountID] = {role: current?.role, pendingAction: null}; return member; @@ -847,6 +891,12 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I }, isOptimisticReport: true, hasOutstandingChildRequest, + pendingChatMembers: [ + { + accountID: accountID.toString(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ], }, }); workspaceMembersChats.onyxOptimisticData.push({ @@ -866,6 +916,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I createChat: null, }, isOptimisticReport: false, + pendingChatMembers: null, }, }); workspaceMembersChats.onyxSuccessData.push({ @@ -943,6 +994,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount }, ...newPersonalDetailsOnyxData.finallyData, ...membersChats.onyxSuccessData, + ...announceRoomMembers.onyxSuccessData, ]; const failureData: OnyxUpdate[] = [ @@ -1577,7 +1629,6 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName expenseReportActionData, expenseCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); - const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, @@ -1701,6 +1752,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName addWorkspaceRoom: null, }, pendingAction: null, + pendingChatMembers: [], }, }, { @@ -2707,6 +2759,16 @@ function declineJoinRequest(reportID: string, reportAction: OnyxEntry<ReportActi API.write(WRITE_COMMANDS.DECLINE_JOIN_REQUEST, parameters, {optimisticData, failureData, successData}); } +function openPolicyDistanceRatesPage(policyID?: string) { + if (!policyID) { + return; + } + + const params: OpenPolicyDistanceRatesPageParams = {policyID}; + + API.read(READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE, params); +} + export { removeMembers, updateWorkspaceMembersRole, @@ -2761,4 +2823,5 @@ export { declineJoinRequest, createPolicyCategory, clearCategoryErrors, + openPolicyDistanceRatesPage, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 94fe324d306a..f3cbabcd453c 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2252,7 +2252,7 @@ function openReportFromDeepLink(url: string, isAuthenticated: boolean) { Navigation.waitForProtectedRoutes().then(() => { const route = ReportUtils.getRouteFromLink(url); - if (route && Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + if (route && Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(route)) { Session.signOutAndRedirectToSignIn(true); return; } @@ -2416,6 +2416,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string const logins = inviteeEmails.map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, inviteeAccountIDs); + const pendingChatMembers = ReportUtils.getPendingChatMembers(inviteeAccountIDs, report?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const optimisticData: OnyxUpdate[] = [ { @@ -2424,13 +2425,22 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string value: { participantAccountIDs: participantAccountIDsAfterInvitation, visibleChatMemberAccountIDs: visibleMemberAccountIDsAfterInvitation, + pendingChatMembers, }, }, ...newPersonalDetailsOnyxData.optimisticData, ]; - const successData: OnyxUpdate[] = newPersonalDetailsOnyxData.finallyData; - + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + pendingChatMembers: report?.pendingChatMembers ?? null, + }, + }, + ...newPersonalDetailsOnyxData.finallyData, + ]; const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -2438,6 +2448,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record<string value: { participantAccountIDs: report.participantAccountIDs, visibleChatMemberAccountIDs: report.visibleChatMemberAccountIDs, + pendingChatMembers: report?.pendingChatMembers ?? null, }, }, ...newPersonalDetailsOnyxData.finallyData, @@ -2459,6 +2470,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { const participantAccountIDsAfterRemoval = report?.participantAccountIDs?.filter((id: number) => !targetAccountIDs.includes(id)); const visibleChatMemberAccountIDsAfterRemoval = report?.visibleChatMemberAccountIDs?.filter((id: number) => !targetAccountIDs.includes(id)); + const pendingChatMembers = ReportUtils.getPendingChatMembers(targetAccountIDs, report?.pendingChatMembers ?? [], CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const optimisticData: OnyxUpdate[] = [ { @@ -2467,6 +2479,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { value: { participantAccountIDs: participantAccountIDsAfterRemoval, visibleChatMemberAccountIDs: visibleChatMemberAccountIDsAfterRemoval, + pendingChatMembers, }, }, ]; @@ -2478,6 +2491,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { value: { participantAccountIDs: report?.participantAccountIDs, visibleChatMemberAccountIDs: report?.visibleChatMemberAccountIDs, + pendingChatMembers: report?.pendingChatMembers ?? null, }, }, ]; @@ -2491,6 +2505,7 @@ function removeFromRoom(reportID: string, targetAccountIDs: number[]) { value: { participantAccountIDs: participantAccountIDsAfterRemoval, visibleChatMemberAccountIDs: visibleChatMemberAccountIDsAfterRemoval, + pendingChatMembers: report?.pendingChatMembers ?? null, }, }, ]; @@ -2889,6 +2904,17 @@ function clearNewRoomFormError() { }); } +function getReportDraftStatus(reportID: string) { + if (!allReports) { + return false; + } + + if (!allReports[reportID]) { + return false; + } + return allReports[reportID]?.hasDraft; +} + function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEntry<ReportAction>, resolution: ValueOf<typeof CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION>) { const message = reportAction?.message?.[0]; if (!message) { @@ -2939,6 +2965,7 @@ function resolveActionableMentionWhisper(reportId: string, reportAction: OnyxEnt } export { + getReportDraftStatus, searchInServer, addComment, addAttachment, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 6a0f53c3d058..07bc7f3ed418 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -925,7 +925,7 @@ function signInWithValidateCodeAndNavigate(accountID: number, validateCode: stri if (exitTo) { handleExitToNavigation(exitTo); } else { - Navigation.navigate(ROUTES.HOME); + Navigation.goBack(); } } @@ -935,7 +935,7 @@ function signInWithValidateCodeAndNavigate(accountID: number, validateCode: stri * @param {string} route */ -const canAccessRouteByAnonymousUser = (route: string) => { +const canAnonymousUserAccessRoute = (route: string) => { const reportID = ReportUtils.getReportIDFromLink(route); if (reportID) { return true; @@ -948,9 +948,10 @@ const canAccessRouteByAnonymousUser = (route: string) => { if (route.startsWith('/')) { routeRemovedReportId = routeRemovedReportId.slice(1); } - const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE]; + const routesAccessibleByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE]; + const isMagicLink = CONST.REGEX.ROUTES.VALIDATE_LOGIN.test(`/${route}`); - if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { + if ((routesAccessibleByAnonymousUser as string[]).includes(routeRemovedReportId) || isMagicLink) { return true; } return false; @@ -986,7 +987,7 @@ export { toggleTwoFactorAuth, validateTwoFactorAuth, waitForUserSignIn, - canAccessRouteByAnonymousUser, + canAnonymousUserAccessRoute, signInWithSupportAuthToken, isSupportAuthToken, hasStashedSession, diff --git a/src/libs/navigateAfterJoinRequest/index.ts b/src/libs/navigateAfterJoinRequest/index.ts index b9e533208ec2..b53c59d678c9 100644 --- a/src/libs/navigateAfterJoinRequest/index.ts +++ b/src/libs/navigateAfterJoinRequest/index.ts @@ -4,5 +4,6 @@ import ROUTES from '@src/ROUTES'; const navigateAfterJoinRequest = () => { Navigation.goBack(undefined, false, true); Navigation.navigate(ROUTES.ALL_SETTINGS); + Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); }; export default navigateAfterJoinRequest; diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index 24d696ca2fb0..a2b2f094ac26 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -50,8 +50,10 @@ const getAllParticipants = ( !!userPersonalDetail?.login && !CONST.RESTRICTED_ACCOUNT_IDS.includes(accountID) ? LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login) : translate('common.hidden'); const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail); + const pendingChatMember = report?.pendingChatMembers?.find((member) => member.accountID === accountID.toString()); return { alternateText: userLogin, + pendingAction: pendingChatMember?.pendingAction, displayName, accountID: userPersonalDetail?.accountID ?? accountID, icons: [ diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 557211dc0235..47ba5174e4b0 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -183,6 +183,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { return; } } + const pendingChatMember = report?.pendingChatMembers?.find((member) => member.accountID === accountID.toString()); result.push({ keyForList: String(accountID), @@ -199,6 +200,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { id: Number(accountID), }, ], + pendingAction: pendingChatMember?.pendingAction, }); }); diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx new file mode 100644 index 000000000000..8db9e05a5139 --- /dev/null +++ b/src/pages/TransactionReceiptPage.tsx @@ -0,0 +1,79 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import AttachmentModal from '@components/AttachmentModal'; +import Navigation from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as ReceiptUtils from '@libs/ReceiptUtils'; +import * as ReportActionUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import * as ReportActions from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Report, ReportMetadata, Transaction} from '@src/types/onyx'; + +type TransactionReceiptOnyxProps = { + report: OnyxEntry<Report>; + transaction: OnyxEntry<Transaction>; + reportMetadata: OnyxEntry<ReportMetadata>; +}; + +type TransactionReceiptProps = TransactionReceiptOnyxProps & StackScreenProps<AuthScreensParamList, typeof SCREENS.TRANSACTION_RECEIPT>; + +function TransactionReceipt({transaction, report, reportMetadata = {isLoadingInitialReportActions: true}, route}: TransactionReceiptProps) { + const receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction); + + const imageSource = tryResolveUrlFromApiRoot(receiptURIs.image); + + const isLocalFile = receiptURIs.isLocalFile; + + const parentReportAction = ReportActionUtils.getReportAction(report?.parentReportID ?? '', report?.parentReportActionID ?? ''); + const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT); + const isEReceipt = transaction && TransactionUtils.hasEReceipt(transaction); + + useEffect(() => { + if (report && transaction) { + return; + } + ReportActions.openReport(route.params.reportID); + // I'm disabling the warning, as it expects to use exhaustive deps, even though we want this useEffect to run only on the first render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <AttachmentModal + source={imageSource} + isAuthTokenRequired={!isLocalFile} + report={report} + isReceiptAttachment + canEditReceipt={canEditReceipt} + allowDownload={!isEReceipt} + originalFileName={receiptURIs?.filename} + defaultOpen + onModalClose={() => { + Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID ?? '')); + }} + isLoading={!transaction && reportMetadata?.isLoadingInitialReportActions} + shouldShowNotFoundPage={(report?.parentReportID ?? '') !== transaction?.reportID} + /> + ); +} + +TransactionReceipt.displayName = 'TransactionReceipt'; + +export default withOnyx<TransactionReceiptProps, TransactionReceiptOnyxProps>({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID ?? '0'}`, + }, + transaction: { + key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID ?? '0'}`, + }, + reportMetadata: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID ?? '0'}`, + }, +})(TransactionReceipt); diff --git a/src/pages/ValidateLoginPage/index.tsx b/src/pages/ValidateLoginPage/index.tsx index 2289547afe56..d7e975890186 100644 --- a/src/pages/ValidateLoginPage/index.tsx +++ b/src/pages/ValidateLoginPage/index.tsx @@ -16,7 +16,7 @@ function ValidateLoginPage({ useEffect(() => { // Wait till navigation becomes available Navigation.isNavigationReady().then(() => { - if (session?.authToken) { + if (session?.authToken && session?.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS) { // If already signed in, do not show the validate code if not on web, // because we don't want to block the user with the interstitial page. if (exitTo) { diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx index 866b061d964f..2acad7815754 100644 --- a/src/pages/ValidateLoginPage/index.website.tsx +++ b/src/pages/ValidateLoginPage/index.website.tsx @@ -20,7 +20,7 @@ function ValidateLoginPage({ }: ValidateLoginPageProps<ValidateLoginPageOnyxProps>) { const login = credentials?.login; const autoAuthState = session?.autoAuthState ?? CONST.AUTO_AUTH_STATE.NOT_STARTED; - const isSignedIn = !!session?.authToken; + const isSignedIn = !!session?.authToken && session?.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS; const is2FARequired = !!account?.requiresTwoFactorAuth; const cachedAccountID = credentials?.accountID; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index da5a8e4aae27..2e19a2c6a940 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Banner from '@components/Banner'; @@ -387,8 +387,13 @@ function ReportScreen({ Performance.markEnd(CONST.TIMING.CHAT_RENDER); fetchReportIfNeeded(); - ComposerActions.setShouldShowComposeInput(true); + const interactionTask = InteractionManager.runAfterInteractions(() => { + ComposerActions.setShouldShowComposeInput(true); + }); return () => { + if (interactionTask) { + interactionTask.cancel(); + } if (!didSubscribeToReportLeavingEvents) { return; } @@ -474,10 +479,20 @@ function ReportScreen({ // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. // Existing reports created will have empty fields for `pendingFields`. const didCreateReportSuccessfully = !report.pendingFields || (!report.pendingFields.addWorkspaceRoom && !report.pendingFields.createChat); + let interactionTask; if (!didSubscribeToReportLeavingEvents.current && didCreateReportSuccessfully) { - Report.subscribeToReportLeavingEvents(reportID); - didSubscribeToReportLeavingEvents.current = true; + interactionTask = InteractionManager.runAfterInteractions(() => { + Report.subscribeToReportLeavingEvents(reportID); + didSubscribeToReportLeavingEvents.current = true; + }); } + + return () => { + if (!interactionTask) { + return; + } + interactionTask.cancel(); + }; }, [report, didSubscribeToReportLeavingEvents, reportID]); const onListLayout = useCallback((e) => { @@ -571,8 +586,8 @@ function ReportScreen({ )} {/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded. - If we prevent rendering the report while they are loading then - we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && <ReportActionsSkeletonView />} {isReportReadyForDisplay ? ( @@ -584,9 +599,7 @@ function ReportScreen({ isEmptyChat={isEmptyChat} lastReportAction={lastReportAction} /> - ) : ( - <ReportFooter isReportReadyForDisplay={false} /> - )} + ) : null} </View> </DragAndDropProvider> </FullPageNotFoundView> diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index af2d0b9eab56..308eac71b5b7 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -419,15 +419,23 @@ function ComposerWithSuggestions( Report.setReportWithDraft(reportID, true); } + const hasDraftStatus = Report.getReportDraftStatus(reportID); + + /** + * The extra `!hasDraftStatus` check is to prevent the draft being set + * when the user navigates to the ReportScreen. This doesn't alter anything + * in terms of functionality. + */ // The draft has been deleted. - if (newCommentConverted.length === 0) { + if (newCommentConverted.length === 0 && hasDraftStatus) { Report.setReportWithDraft(reportID, false); } commentRef.current = newCommentConverted; + const isDraftCommentEmpty = getDraftComment(reportID) === ''; if (shouldDebounceSaveComment) { debouncedSaveReportComment(reportID, newCommentConverted); - } else { + } else if (isDraftCommentEmpty && newCommentConverted.length !== 0) { Report.saveReportComment(reportID, newCommentConverted || ''); } if (newCommentConverted) { @@ -676,13 +684,6 @@ function ComposerWithSuggestions( useEffect(() => { // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus); - - if (value.length === 0) { - return; - } - - Report.setReportWithDraft(reportID, true); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useImperativeHandle( diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx index 1abc6567bc7b..f3780528cabe 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -17,7 +17,17 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme const prevPreferredLocale = usePrevious(preferredLocale); useEffect(() => { + /** + * Schedules the callback to run when the main thread is idle. + */ + if ('requestIdleCallback' in window) { + const callbackID = requestIdleCallback(() => { + updateComment(comment ?? ''); + }); + return () => cancelIdleCallback(callbackID); + } updateComment(comment ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount }, []); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index cc8676467958..7285f550d3ca 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -435,7 +435,9 @@ function ReportActionItem({ /> ); } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { - children = ( + children = ReportUtils.isClosedExpenseReportWithNoExpenses(iouReport) ? ( + <RenderHTML html={`<comment>${translate('parentReportAction.deletedReport')}</comment>`} /> + ) : ( <ReportPreview iouReportID={ReportActionsUtils.getIOUReportIDFromReportActionPreview(action)} chatReportID={report.reportID} @@ -878,16 +880,16 @@ export default withOnyx<ReportActionItemProps, ReportActionItemOnyxProps>({ iouReport: { key: ({action}) => { const iouReportID = ReportActionsUtils.getIOUReportIDFromReportActionPreview(action); - return `${ONYXKEYS.COLLECTION.REPORT}${iouReportID ?? ''}`; + return `${ONYXKEYS.COLLECTION.REPORT}${iouReportID ?? 0}`; }, initialValue: {} as OnyxTypes.Report, }, policyReportFields: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? ''}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? 0}`, initialValue: {}, }, policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? ''}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? 0}`, initialValue: {} as OnyxTypes.Policy, }, emojiReactions: { diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index dabf7a9f8d36..4711a105a8f1 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -125,6 +125,8 @@ function isMessageUnread(message, lastReadTime) { return Boolean(message && lastReadTime && message.created && lastReadTime < message.created); } +const onScrollToIndexFailed = () => {}; + function ReportActionsList({ report, parentReportAction, @@ -314,7 +316,9 @@ function ReportActionsList({ if (unsubscribe) { unsubscribe(); } - Report.unsubscribeFromReportChannel(report.reportID); + InteractionManager.runAfterInteractions(() => { + Report.unsubscribeFromReportChannel(report.reportID); + }); }; newActionUnsubscribeMap[report.reportID] = cleanup; @@ -341,11 +345,12 @@ function ReportActionsList({ } }; - const trackVerticalScrolling = (event) => { + const trackVerticalScrolling = useCallback((event) => { scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; handleUnreadFloatingButton(); onScroll(event); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const scrollToBottomAndMarkReportAsRead = () => { reportScrollManager.scrollToBottom(); @@ -493,7 +498,7 @@ function ReportActionsList({ // Native mobile does not render updates flatlist the changes even though component did update called. // To notify there something changes we can use extraData prop to flatlist - const extraData = [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)]; + const extraData = useMemo(() => [isSmallScreenWidth ? currentUnreadMarker : undefined, ReportUtils.isArchivedRoom(report)], [currentUnreadMarker, isSmallScreenWidth, report]); const hideComposer = !ReportUtils.canUserPerformWriteAction(report); const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; @@ -570,7 +575,7 @@ function ReportActionsList({ keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} onScroll={trackVerticalScrolling} - onScrollToIndexFailed={() => {}} + onScrollToIndexFailed={onScrollToIndexFailed} extraData={extraData} /> </Animated.View> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index ca3ee7d2ab6a..e1c98a22bec8 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import {InteractionManager} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; @@ -117,7 +118,15 @@ function ReportActionsView(props) { }; useEffect(() => { - openReportIfNecessary(); + const interactionTask = InteractionManager.runAfterInteractions(() => { + openReportIfNecessary(); + }); + return () => { + if (!interactionTask) { + return; + } + interactionTask.cancel(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -171,10 +180,20 @@ function ReportActionsView(props) { // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. // Existing reports created will have empty fields for `pendingFields`. const didCreateReportSuccessfully = !props.report.pendingFields || (!props.report.pendingFields.addWorkspaceRoom && !props.report.pendingFields.createChat); + let interactionTask; if (!didSubscribeToReportTypingEvents.current && didCreateReportSuccessfully) { - Report.subscribeToReportTypingEvents(reportID); - didSubscribeToReportTypingEvents.current = true; + interactionTask = InteractionManager.runAfterInteractions(() => { + Report.subscribeToReportTypingEvents(reportID); + didSubscribeToReportTypingEvents.current = true; + }); } + + return () => { + if (!interactionTask) { + return; + } + interactionTask.cancel(); + }; }, [props.report.pendingFields, didSubscribeToReportTypingEvents, reportID]); const oldestReportAction = useMemo(() => _.last(props.reportActions), [props.reportActions]); diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 0a97f00c5002..dc5d5d4d07dd 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,7 +1,7 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -173,5 +173,5 @@ export default withOnyx({ activePolicy: { key: ({activeWorkspaceID}) => `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`, }, -})(SidebarLinks); +})(memo(SidebarLinks)); export {basePropTypes}; diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx index 428df32bf032..8111e8d39afa 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import AccountUtils from '@libs/AccountUtils'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -76,8 +77,7 @@ function BaseValidateCodeForm({account, credentials, session, autoComplete, isUs const hasError = !!account && !isEmptyObject(account?.errors) && !needToClearError; const isLoadingResendValidationForm = account?.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM; const shouldDisableResendValidateCode = isOffline ?? account?.isLoading; - const isValidateCodeFormSubmitting = - account?.isLoading && account?.loadingForm === (account?.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM); + const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); useEffect(() => { if (!(inputValidateCodeRef.current && hasError && (session?.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || account?.isLoading))) { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index c4f4d6399dbd..745f95c30c80 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -162,6 +162,12 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.TAGS, }, + { + translationKey: 'workspace.common.distanceRates', + icon: Expensicons.Car, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES.getRoute(policyID)))), + routeName: SCREENS.WORKSPACE.DISTANCE_RATES, + }, ]; const menuItems: WorkspaceMenuItem[] = [ diff --git a/src/pages/workspace/WorkspaceJoinUserPage.tsx b/src/pages/workspace/WorkspaceJoinUserPage.tsx index 8167e6fc1ebf..ffcf871ae70d 100644 --- a/src/pages/workspace/WorkspaceJoinUserPage.tsx +++ b/src/pages/workspace/WorkspaceJoinUserPage.tsx @@ -36,7 +36,7 @@ function WorkspaceJoinUserPage({route, policies}: WorkspaceJoinUserPageProps) { if (!isJoinLinkUsed) { return; } - Navigation.goBack(undefined, false, true); + navigateAfterJoinRequest(); }, []); useEffect(() => { diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 42f29f885c00..2715a008c991 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -87,6 +87,10 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se const {isSmallScreenWidth} = useWindowDimensions(); const dropdownButtonRef = useRef(null); const isPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); + const isLoading = useMemo( + () => !isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers)), + [isOfflineAndNoMemberDataAvailable, personalDetails, policyMembers], + ); /** * Get filtered personalDetails list with current policyMembers @@ -257,15 +261,11 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se /** Opens the member details page */ const openMemberDetails = useCallback( (item: MemberOption) => { - if (!isPolicyAdmin) { + if (!isPolicyAdmin || !PolicyUtils.isPaidGroupPolicy(policy)) { Navigation.navigate(ROUTES.PROFILE.getRoute(item.accountID)); return; } - if (!PolicyUtils.isPaidGroupPolicy(policy)) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(route.params.policyID, item.accountID, Navigation.getActiveRoute())); }, [isPolicyAdmin, policy, route.params.policyID], @@ -376,7 +376,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se if (isOfflineAndNoMemberDataAvailable) { return translate('workspace.common.mustBeOnlineToViewMembers'); } - return !data.length ? translate('workspace.common.memberNotFound') : ''; + + return !isLoading && isEmptyObject(policyMembers) ? translate('workspace.common.memberNotFound') : ''; }; const getHeaderContent = () => ( @@ -468,7 +469,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se <ButtonWithDropdownMenu<WorkspaceMemberBulkActionType> shouldAlwaysShowDropdownMenu pressOnEnter - customText={translate('workspace.people.selected', {selectedNumber: selectedEmployees.length})} + customText={translate('workspace.common.selected', {selectedNumber: selectedEmployees.length})} buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} onPress={() => null} options={getBulkActionsButtonOptions()} @@ -546,7 +547,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se onCheckboxPress={(item) => toggleUser(item.accountID)} onSelectAll={() => toggleAllUsers(data)} onDismissError={dismissError} - showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers))} + showLoadingPlaceholder={isLoading} showScrollIndicator shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} ref={textInputRef} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 52d18d8de276..17afefd1cea4 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -143,6 +143,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat medium onPress={navigateToCategoriesSettings} icon={Expensicons.Gear} + iconStyles={[styles.mr2]} text={translate('common.settings')} style={[isSmallScreenWidth && styles.w50]} /> @@ -166,7 +167,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat {!isSmallScreenWidth && headerButtons} </HeaderWithBackButton> {isSmallScreenWidth && <View style={[styles.pl5, styles.pr5]}>{headerButtons}</View>} - <View style={[styles.ph5, styles.pb5]}> + <View style={[styles.ph5, styles.pb5, styles.pt3]}> <Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.categories.subtitle')}</Text> </View> {isLoading && ( diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx new file mode 100644 index 000000000000..fd6466da1758 --- /dev/null +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -0,0 +1,292 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {ActivityIndicator, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import type {DropdownOption, WorkspaceDistanceRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import * as Illustrations from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import TableListItem from '@components/SelectionList/TableListItem'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import type {CentralPaneNavigatorParamList} from '@navigation/types'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy'; +import ButtonWithDropdownMenu from '@src/components/ButtonWithDropdownMenu'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; + +type RateForList = { + value: string; + text: string; + keyForList: string; + isSelected: boolean; + rightElement: React.ReactNode; +}; + +type PolicyDistanceRatesPageOnyxProps = { + /** Policy details */ + policy: OnyxEntry<OnyxTypes.Policy>; +}; + +type PolicyDistanceRatesPageProps = PolicyDistanceRatesPageOnyxProps & StackScreenProps<CentralPaneNavigatorParamList, typeof SCREENS.WORKSPACE.DISTANCE_RATES>; + +function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) { + const {isSmallScreenWidth} = useWindowDimensions(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const [selectedDistanceRates, setSelectedDistanceRates] = useState<Rate[]>([]); + const [isWarningModalVisible, setIsWarningModalVisible] = useState(false); + const dropdownButtonRef = useRef(null); + + const customUnit: CustomUnit | undefined = useMemo( + () => (policy?.customUnits !== undefined ? policy?.customUnits[Object.keys(policy?.customUnits)[0]] : undefined), + [policy?.customUnits], + ); + const customUnitRates: Record<string, Rate> = useMemo(() => customUnit?.rates ?? {}, [customUnit]); + + function fetchDistanceRates() { + Policy.openPolicyDistanceRatesPage(route.params.policyID); + } + + const {isOffline} = useNetwork({onReconnect: fetchDistanceRates}); + + useEffect(() => { + fetchDistanceRates(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const distanceRatesList = useMemo<RateForList[]>( + () => + Object.values(customUnitRates).map((value) => ({ + value: value.customUnitRateID ?? '', + text: `${CurrencyUtils.convertAmountToDisplayString(value.rate, value.currency ?? CONST.CURRENCY.USD)} / ${translate( + `common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`, + )}`, + keyForList: value.customUnitRateID ?? '', + isSelected: selectedDistanceRates.find((rate) => rate.customUnitRateID === value.customUnitRateID) !== undefined, + rightElement: ( + <View style={styles.flexRow}> + <Text style={[styles.alignSelfCenter, !value.enabled && styles.textSupporting]}> + {value.enabled ? translate('workspace.distanceRates.enabled') : translate('workspace.distanceRates.disabled')} + </Text> + <View style={[styles.p1, styles.pl2]}> + <Icon + src={Expensicons.ArrowRight} + fill={theme.icon} + /> + </View> + </View> + ), + })), + [customUnit?.attributes?.unit, customUnitRates, selectedDistanceRates, styles.alignSelfCenter, styles.flexRow, styles.p1, styles.pl2, styles.textSupporting, theme.icon, translate], + ); + + const addRate = () => { + // Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(route.params.policyID)); + }; + + const openSettings = () => { + // Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(route.params.policyID)); + }; + + const editRate = () => { + // Navigation.navigate(ROUTES.WORKSPACE_EDIT_DISTANCE_RATE.getRoute(route.params.policyID, rateID)); + }; + + const disableRates = () => { + if (selectedDistanceRates.length !== Object.values(customUnitRates).length) { + // run enableWorkspaceDistanceRates for all selected rows + return; + } + + setIsWarningModalVisible(true); + }; + + const enableRates = () => { + // run enableWorkspaceDistanceRates for all selected rows + }; + + const deleteRates = () => { + if (selectedDistanceRates.length !== Object.values(customUnitRates).length) { + // run deleteWorkspaceDistanceRates for all selected rows + return; + } + + setIsWarningModalVisible(true); + }; + + const toggleRate = (rate: RateForList) => { + if (selectedDistanceRates.find((selectedRate) => selectedRate.customUnitRateID === rate.value) !== undefined) { + setSelectedDistanceRates((prev) => prev.filter((selectedRate) => selectedRate.customUnitRateID !== rate.value)); + } else { + setSelectedDistanceRates((prev) => [...prev, customUnitRates[rate.value]]); + } + }; + + const toggleAllRates = () => { + if (selectedDistanceRates.length === Object.values(customUnitRates).length) { + setSelectedDistanceRates([]); + } else { + setSelectedDistanceRates([...Object.values(customUnitRates)]); + } + }; + + const getCustomListHeader = () => ( + <View style={[styles.flex1, styles.flexRow, styles.justifyContentBetween, styles.pl3, styles.pr9]}> + <Text style={styles.searchInputStyle}>{translate('workspace.distanceRates.rate')}</Text> + <Text style={[styles.searchInputStyle, styles.textAlignCenter]}>{translate('statusPage.status')}</Text> + </View> + ); + + const getBulkActionsButtonOptions = () => { + const options: Array<DropdownOption<WorkspaceDistanceRatesBulkActionType>> = [ + { + text: translate(`workspace.distanceRates.${selectedDistanceRates.length <= 1 ? 'deleteRate' : 'deleteRates'}`), + value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.DELETE, + icon: Expensicons.Trashcan, + onSelected: deleteRates, + }, + ]; + + const enabledRates = selectedDistanceRates.filter((rate) => rate.enabled); + if (enabledRates.length > 0) { + options.push({ + text: translate(`workspace.distanceRates.${enabledRates.length <= 1 ? 'disableRate' : 'disableRates'}`), + value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.DISABLE, + icon: Expensicons.DocumentSlash, + onSelected: disableRates, + }); + } + + const disabledRates = selectedDistanceRates.filter((rate) => !rate.enabled); + if (disabledRates.length > 0) { + options.push({ + text: translate(`workspace.distanceRates.${disabledRates.length <= 1 ? 'enableRate' : 'enableRates'}`), + value: CONST.POLICY.DISTANCE_RATES_BULK_ACTION_TYPES.ENABLE, + icon: Expensicons.DocumentSlash, + onSelected: enableRates, + }); + } + + return options; + }; + + const isLoading = !isOffline && customUnit === undefined; + + const headerButtons = ( + <View style={[styles.w100, styles.flexRow, isSmallScreenWidth && styles.mb3]}> + {selectedDistanceRates.length === 0 ? ( + <> + <Button + medium + text={translate('workspace.distanceRates.addRate')} + onPress={addRate} + style={[styles.mr3, isSmallScreenWidth && styles.flexGrow1]} + icon={Expensicons.Plus} + iconStyles={[styles.mr2]} + success + /> + + <Button + medium + text={translate('workspace.common.settings')} + onPress={openSettings} + style={[isSmallScreenWidth && styles.flexGrow1]} + icon={Expensicons.Gear} + iconStyles={[styles.mr2]} + /> + </> + ) : ( + <ButtonWithDropdownMenu<WorkspaceDistanceRatesBulkActionType> + shouldAlwaysShowDropdownMenu + pressOnEnter + customText={translate('workspace.common.selected', {selectedNumber: selectedDistanceRates.length})} + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={getBulkActionsButtonOptions()} + buttonRef={dropdownButtonRef} + style={[isSmallScreenWidth && styles.flexGrow1]} + wrapperStyle={styles.w100} + /> + )} + </View> + ); + + return ( + <AdminPolicyAccessOrNotFoundWrapper policyID={route.params.policyID}> + <PaidPolicyAccessOrNotFoundWrapper policyID={route.params.policyID}> + <ScreenWrapper + includeSafeAreaPaddingBottom={false} + style={[styles.defaultModalContainer]} + testID={PolicyDistanceRatesPage.displayName} + shouldShowOfflineIndicatorInWideScreen + > + <HeaderWithBackButton + icon={Illustrations.CarIce} + title={translate('workspace.common.distanceRates')} + shouldShowBackButton={isSmallScreenWidth} + > + {!isSmallScreenWidth && headerButtons} + </HeaderWithBackButton> + {isSmallScreenWidth && <View style={[styles.ph5]}>{headerButtons}</View>} + <View style={[styles.ph5, styles.pb5, styles.pt3]}> + <Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.distanceRates.centrallyManage')}</Text> + </View> + {isLoading && ( + <ActivityIndicator + size={CONST.ACTIVITY_INDICATOR_SIZE.LARGE} + style={[styles.flex1]} + color={theme.spinner} + /> + )} + {Object.values(customUnitRates).length > 0 && ( + <SelectionList + canSelectMultiple + ListItem={TableListItem} + onSelectAll={toggleAllRates} + onCheckboxPress={toggleRate} + sections={[{data: distanceRatesList, indexOffset: 0, isDisabled: false}]} + onSelectRow={editRate} + showScrollIndicator + customListHeader={getCustomListHeader()} + listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} + /> + )} + <ConfirmModal + onConfirm={() => setIsWarningModalVisible(false)} + isVisible={isWarningModalVisible} + title={translate('workspace.distanceRates.oopsNotSoFast')} + prompt={translate('workspace.distanceRates.workspaceNeeds')} + confirmText={translate('common.buttonConfirm')} + shouldShowCancelButton={false} + /> + </ScreenWrapper> + </PaidPolicyAccessOrNotFoundWrapper> + </AdminPolicyAccessOrNotFoundWrapper> + ); +} + +PolicyDistanceRatesPage.displayName = 'PolicyDistanceRatesPage'; + +export default withOnyx<PolicyDistanceRatesPageProps, PolicyDistanceRatesPageOnyxProps>({ + policy: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, + }, +})(PolicyDistanceRatesPage); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index c82740eff361..fb7611ae681c 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -107,7 +107,7 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) { title={translate('workspace.common.tags')} shouldShowBackButton={isSmallScreenWidth} /> - <View style={[styles.ph5, styles.pb5]}> + <View style={[styles.ph5, styles.pb5, styles.pt3]}> <Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.tags.subtitle')}</Text> </View> {tagList.length ? ( diff --git a/src/stories/InlineSystemMessage.stories.js b/src/stories/InlineSystemMessage.stories.tsx similarity index 59% rename from src/stories/InlineSystemMessage.stories.js rename to src/stories/InlineSystemMessage.stories.tsx index b7fe21c8b10e..5c00a41ac479 100644 --- a/src/stories/InlineSystemMessage.stories.js +++ b/src/stories/InlineSystemMessage.stories.tsx @@ -1,24 +1,28 @@ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React from 'react'; import InlineSystemMessage from '@components/InlineSystemMessage'; +import type {InlineSystemMessageProps} from '@components/InlineSystemMessage'; + +type InlineSystemMessageStory = ComponentStory<typeof InlineSystemMessage>; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta<typeof InlineSystemMessage> = { title: 'Components/InlineSystemMessage', component: InlineSystemMessage, }; -function Template(args) { +function Template(props: InlineSystemMessageProps) { // eslint-disable-next-line react/jsx-props-no-spreading - return <InlineSystemMessage {...args} />; + return <InlineSystemMessage {...props} />; } // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Default = Template.bind({}); +const Default: InlineSystemMessageStory = Template.bind({}); Default.args = { message: 'This is an error message', }; diff --git a/src/stories/MagicCodeInput.stories.js b/src/stories/MagicCodeInput.stories.tsx similarity index 70% rename from src/stories/MagicCodeInput.stories.js rename to src/stories/MagicCodeInput.stories.tsx index 14b234996ce1..bb86c1685593 100644 --- a/src/stories/MagicCodeInput.stories.js +++ b/src/stories/MagicCodeInput.stories.tsx @@ -1,24 +1,28 @@ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React, {useState} from 'react'; import MagicCodeInput from '@components/MagicCodeInput'; +import type {MagicCodeInputProps} from '@components/MagicCodeInput'; + +type MagicCodeInputStory = ComponentStory<typeof MagicCodeInput>; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta<typeof MagicCodeInput> = { title: 'Components/MagicCodeInput', component: MagicCodeInput, }; -function Template(args) { +function Template(props: MagicCodeInputProps) { const [value, setValue] = useState(''); return ( <MagicCodeInput value={value} onChangeText={setValue} // eslint-disable-next-line react/jsx-props-no-spreading - {...args} + {...props} /> ); } @@ -26,17 +30,15 @@ function Template(args) { // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const AutoFocus = Template.bind({}); +const AutoFocus: MagicCodeInputStory = Template.bind({}); AutoFocus.args = { - label: 'Auto-focused magic code input', name: 'AutoFocus', autoFocus: true, autoComplete: 'one-time-code', }; -const SubmitOnComplete = Template.bind({}); +const SubmitOnComplete: MagicCodeInputStory = Template.bind({}); SubmitOnComplete.args = { - label: 'Submits when the magic code input is complete', name: 'SubmitOnComplete', autoComplete: 'one-time-code', shouldSubmitOnComplete: true, diff --git a/src/stories/Picker.stories.js b/src/stories/Picker.stories.tsx similarity index 77% rename from src/stories/Picker.stories.js rename to src/stories/Picker.stories.tsx index b42cfed8f471..a277db387f79 100644 --- a/src/stories/Picker.stories.js +++ b/src/stories/Picker.stories.tsx @@ -1,25 +1,30 @@ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React, {useState} from 'react'; import Picker from '@components/Picker'; +import type {BasePickerProps} from '@components/Picker/types'; + +type PickerStory = ComponentStory<typeof Picker<string>>; + +type TemplateProps = Omit<BasePickerProps<string>, 'onInputChange'>; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta<typeof Picker> = { title: 'Components/Picker', component: Picker, }; -// eslint-disable-next-line react/jsx-props-no-spreading -function Template(args) { +function Template(props: TemplateProps) { const [value, setValue] = useState(''); return ( <Picker value={value} onInputChange={(e) => setValue(e)} // eslint-disable-next-line react/jsx-props-no-spreading - {...args} + {...props} /> ); } @@ -27,10 +32,9 @@ function Template(args) { // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Default = Template.bind({}); +const Default: PickerStory = Template.bind({}); Default.args = { label: 'Default picker', - name: 'Default', hintText: 'Default hint text', items: [ { @@ -44,10 +48,9 @@ Default.args = { ], }; -const PickerWithValue = Template.bind({}); +const PickerWithValue: PickerStory = Template.bind({}); PickerWithValue.args = { label: 'Picker with defined value', - name: 'Picker with defined value', value: 'apple', hintText: 'Picker with hint text', items: [ @@ -62,10 +65,9 @@ PickerWithValue.args = { ], }; -const ErrorStory = Template.bind({}); +const ErrorStory: PickerStory = Template.bind({}); ErrorStory.args = { label: 'Picker with error', - name: 'PickerWithError', hintText: 'Picker hint text', errorText: 'This field has an error.', items: [ @@ -80,10 +82,9 @@ ErrorStory.args = { ], }; -const Disabled = Template.bind({}); +const Disabled: PickerStory = Template.bind({}); Disabled.args = { label: 'Picker disabled', - name: 'Disabled', value: 'orange', isDisabled: true, hintText: 'Picker hint text', diff --git a/src/stories/TextInput.stories.js b/src/stories/TextInput.stories.tsx similarity index 72% rename from src/stories/TextInput.stories.js rename to src/stories/TextInput.stories.tsx index dd2fcced68b0..b8e647949c0f 100644 --- a/src/stories/TextInput.stories.js +++ b/src/stories/TextInput.stories.tsx @@ -1,66 +1,70 @@ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React, {useState} from 'react'; import TextInput from '@components/TextInput'; +import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; + +type TextInputStory = ComponentStory<typeof TextInput>; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta<typeof TextInput> = { title: 'Components/TextInput', component: TextInput, }; -function Template(args) { +function Template(props: BaseTextInputProps) { // eslint-disable-next-line react/jsx-props-no-spreading - return <TextInput {...args} />; + return <TextInput {...props} />; } // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const AutoFocus = Template.bind({}); +const AutoFocus: TextInputStory = Template.bind({}); AutoFocus.args = { label: 'Auto-focused text input', name: 'AutoFocus', autoFocus: true, }; -const DefaultInput = Template.bind({}); +const DefaultInput: TextInputStory = Template.bind({}); DefaultInput.args = { label: 'Default text input', name: 'Default', }; -const DefaultValueInput = Template.bind({}); +const DefaultValueInput: TextInputStory = Template.bind({}); DefaultValueInput.args = { label: 'Default value input', name: 'DefaultValue', defaultValue: 'My default value', }; -const ErrorInput = Template.bind({}); +const ErrorInput: TextInputStory = Template.bind({}); ErrorInput.args = { label: 'Error input', name: 'InputWithError', errorText: "Oops! Looks like there's an error", }; -const ForceActiveLabel = Template.bind({}); +const ForceActiveLabel: TextInputStory = Template.bind({}); ForceActiveLabel.args = { label: 'Force active label', placeholder: 'My placeholder text', forceActiveLabel: true, }; -const PlaceholderInput = Template.bind({}); +const PlaceholderInput: TextInputStory = Template.bind({}); PlaceholderInput.args = { label: 'Placeholder input', name: 'Placeholder', placeholder: 'My placeholder text', }; -const PrefixedInput = Template.bind({}); +const PrefixedInput: TextInputStory = Template.bind({}); PrefixedInput.args = { label: 'Prefixed input', name: 'Prefixed', @@ -68,7 +72,7 @@ PrefixedInput.args = { prefixCharacter: '@', }; -const MaxLengthInput = Template.bind({}); +const MaxLengthInput: TextInputStory = Template.bind({}); MaxLengthInput.args = { label: 'MaxLength input', name: 'MaxLength', @@ -76,12 +80,12 @@ MaxLengthInput.args = { maxLength: 50, }; -function HintAndErrorInput(args) { +function HintAndErrorInput(props: BaseTextInputProps) { const [error, setError] = useState(''); return ( <TextInput // eslint-disable-next-line react/jsx-props-no-spreading - {...args} + {...props} onChangeText={(value) => { if (value && value.toLowerCase() === 'oops!') { setError("Oops! Looks like there's an error"); @@ -101,23 +105,23 @@ HintAndErrorInput.args = { }; // To use autoGrow we need to control the TextInput's value -function AutoGrowSupportInput(args) { - const [value, setValue] = useState(args.value || ''); +function AutoGrowSupportInput(props: BaseTextInputProps) { + const [value, setValue] = useState(props.value ?? ''); React.useEffect(() => { - setValue(args.value || ''); - }, [args.value]); + setValue(props.value ?? ''); + }, [props.value]); return ( <TextInput // eslint-disable-next-line react/jsx-props-no-spreading - {...args} + {...props} onChangeText={setValue} value={value} /> ); } -const AutoGrowInput = AutoGrowSupportInput.bind({}); +const AutoGrowInput: TextInputStory = AutoGrowSupportInput.bind({}); AutoGrowInput.args = { label: 'Autogrow input', name: 'AutoGrow', @@ -132,7 +136,7 @@ AutoGrowInput.args = { value: '', }; -const AutoGrowHeightInput = AutoGrowSupportInput.bind({}); +const AutoGrowHeightInput: TextInputStory = AutoGrowSupportInput.bind({}); AutoGrowHeightInput.args = { label: 'Autogrowheight input', name: 'AutoGrowHeight', diff --git a/src/stories/Tooltip.stories.js b/src/stories/Tooltip.stories.tsx similarity index 76% rename from src/stories/Tooltip.stories.js rename to src/stories/Tooltip.stories.tsx index 900cd6dedd76..c9caf7bc6496 100644 --- a/src/stories/Tooltip.stories.js +++ b/src/stories/Tooltip.stories.tsx @@ -1,27 +1,29 @@ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React from 'react'; import Tooltip from '@components/Tooltip'; +import type {TooltipExtendedProps} from '@components/Tooltip/types'; + +type TooltipStory = ComponentStory<typeof Tooltip>; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta<typeof Tooltip> = { title: 'Components/Tooltip', component: Tooltip, }; -function Template(args) { +function Template(props: TooltipExtendedProps) { return ( - <div - style={{ - width: 100, - }} - > + <div style={{width: 100}}> <Tooltip // eslint-disable-next-line react/jsx-props-no-spreading - {...args} - maxWidth={args.maxWidth || undefined} + {...props} + // Disable nullish coalescing to handle cases when maxWidth is 0 + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + maxWidth={props.maxWidth || undefined} > <div style={{ @@ -42,7 +44,7 @@ function Template(args) { // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Default = Template.bind({}); +const Default: TooltipStory = Template.bind({}); Default.args = { text: 'Tooltip', numberOfLines: 1, @@ -63,12 +65,7 @@ function RenderContent() { ); return ( - <div - style={{ - width: 100, - }} - > - {/* eslint-disable-next-line react/jsx-props-no-spreading */} + <div style={{width: 100}}> <Tooltip renderTooltipContent={renderTooltipContent}> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} <div diff --git a/src/styles/index.ts b/src/styles/index.ts index 405a05cfce78..303e531ea52d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -750,7 +750,7 @@ const styles = (theme: ThemeColors) => height: 140, }, - pickerSmall: (backgroundColor = theme.highlightBG) => + pickerSmall: (disabled = false, backgroundColor = theme.highlightBG) => ({ inputIOS: { fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, @@ -792,7 +792,7 @@ const styles = (theme: ThemeColors) => height: 26, opacity: 1, backgroundColor, - ...cursor.cursorPointer, + ...(disabled ? cursor.cursorDisabled : cursor.cursorPointer), }, inputAndroid: { fontFamily: FontUtils.fontFamily.platform.EXP_NEUE, @@ -4642,6 +4642,7 @@ const styles = (theme: ThemeColors) => videoPlayerTimeComponentWidth: { width: 40, }, + colorSchemeStyle: (colorScheme: ColorScheme) => ({colorScheme}), updateAnimation: { diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 61b63106ad8a..6ca9a7ab2103 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -155,7 +155,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< isPreventSelfApprovalEnabled?: boolean; /** Whether the self approval or submitting is enabled */ - preventSelfApprovalEnabled?: boolean; + preventSelfApproval?: boolean; /** When the monthly scheduled submit should happen */ autoReportingOffset?: AutoReportingOffset; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 22618bb357d0..1f3b49ff77b0 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -17,6 +17,12 @@ type Note = OnyxCommon.OnyxValueWithOfflineFeedback<{ errors?: OnyxCommon.Errors; }>; +/** The pending member of report */ +type PendingChatMember = { + accountID: string; + pendingAction: OnyxCommon.PendingAction; +}; + type Participant = { hidden: boolean; role?: 'admin' | 'member'; @@ -170,6 +176,9 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< isLoadingPrivateNotes?: boolean; selected?: boolean; + /** Pending members of the report */ + pendingChatMembers?: PendingChatMember[]; + /** If the report contains reportFields, save the field id and its value */ reportFields?: Record<string, PolicyReportField>; }, @@ -180,4 +189,4 @@ type ReportCollectionDataSet = CollectionDataSet<typeof ONYXKEYS.COLLECTION.REPO export default Report; -export type {NotificationPreference, RoomVisibility, WriteCapability, Note, ReportCollectionDataSet}; +export type {NotificationPreference, RoomVisibility, WriteCapability, Note, PendingChatMember, ReportCollectionDataSet}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index 9133eca63c65..28de4582bd5e 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -26,6 +26,9 @@ type TransactionViolation = { isTransactionOlderThan7Days?: boolean; member?: string; taxName?: string; + tagListIndex?: number; + tagListName?: string; + errorIndexes?: number[]; }; }; diff --git a/tests/e2e/utils/installApp.ts b/tests/e2e/utils/installApp.ts index b443344e6f02..dc6a9d64053f 100644 --- a/tests/e2e/utils/installApp.ts +++ b/tests/e2e/utils/installApp.ts @@ -17,7 +17,7 @@ export default function (packageName: string, path: string, platform = 'android' execAsync(`adb uninstall ${packageName}`) .catch((error: ExecException) => { // Ignore errors - Logger.warn('Failed to uninstall app:', error); + Logger.warn('Failed to uninstall app:', error.message); }) // eslint-disable-next-line @typescript-eslint/no-misused-promises .finally(() => execAsync(`adb install ${path}`)) diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.ts similarity index 66% rename from tests/e2e/utils/logger.js rename to tests/e2e/utils/logger.ts index e120c90482b5..ebe8fc05e66a 100644 --- a/tests/e2e/utils/logger.js +++ b/tests/e2e/utils/logger.ts @@ -1,7 +1,6 @@ /* eslint-disable import/no-import-module-exports */ import fs from 'fs'; import path from 'path'; -import _ from 'underscore'; import CONFIG from '../config'; const COLOR_DIM = '\x1b[2m'; @@ -12,7 +11,7 @@ const COLOR_GREEN = '\x1b[32m'; const getDateString = () => `[${Date()}] `; -const writeToLogFile = (...args) => { +const writeToLogFile = (...args: string[]) => { if (!fs.existsSync(CONFIG.LOG_FILE)) { // Check that the directory exists const logDir = path.dirname(CONFIG.LOG_FILE); @@ -24,45 +23,46 @@ const writeToLogFile = (...args) => { } fs.appendFileSync( CONFIG.LOG_FILE, - `${_.map(args, (arg) => { - if (typeof arg === 'string') { - // Remove color codes from arg, because they are not supported in log files - // eslint-disable-next-line no-control-regex - return arg.replace(/\x1b\[\d+m/g, ''); - } - return arg; - }) + `${args + .map((arg) => { + if (typeof arg === 'string') { + // Remove color codes from arg, because they are not supported in log files + // eslint-disable-next-line no-control-regex + return arg.replace(/\x1b\[\d+m/g, ''); + } + return arg; + }) .join(' ') .trim()}\n`, ); }; -const log = (...args) => { +const log = (...args: string[]) => { const argsWithTime = [getDateString(), ...args]; console.debug(...argsWithTime); writeToLogFile(...argsWithTime); }; -const info = (...args) => { +const info = (...args: string[]) => { log('▶️', ...args); }; -const success = (...args) => { +const success = (...args: string[]) => { const lines = ['✅', COLOR_GREEN, ...args, COLOR_RESET]; log(...lines); }; -const warn = (...args) => { +const warn = (...args: string[]) => { const lines = ['⚠️', COLOR_YELLOW, ...args, COLOR_RESET]; log(...lines); }; -const note = (...args) => { +const note = (...args: string[]) => { const lines = [COLOR_DIM, ...args, COLOR_RESET]; log(...lines); }; -const error = (...args) => { +const error = (...args: string[]) => { const lines = ['🔴', COLOR_RED, ...args, COLOR_RESET]; log(...lines); }; diff --git a/tests/perf-test/SignInPage.perf-test.tsx b/tests/perf-test/SignInPage.perf-test.tsx index 3ff62fd60701..e3e2c20ae72a 100644 --- a/tests/perf-test/SignInPage.perf-test.tsx +++ b/tests/perf-test/SignInPage.perf-test.tsx @@ -18,6 +18,14 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +jest.mock('../../src/libs/Log'); + +jest.mock('../../src/libs/API', () => ({ + write: jest.fn(), + makeRequestWithSideEffects: jest.fn(), + read: jest.fn(), +})); + const mockedNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); diff --git a/tests/unit/GithubUtilsTest.ts b/tests/unit/GithubUtilsTest.ts index 794139286527..24e56402f1ea 100644 --- a/tests/unit/GithubUtilsTest.ts +++ b/tests/unit/GithubUtilsTest.ts @@ -376,6 +376,7 @@ describe('GithubUtils', () => { login: 'hubot', }, ], + merged_by: {login: 'octocat'}, }, { number: 7, @@ -392,14 +393,8 @@ describe('GithubUtils', () => { color: 'f29513', }, ], - assignees: [ - { - login: 'octocat', - }, - { - login: 'hubot', - }, - ], + assignees: [], + merged_by: {login: 'hubot'}, }, ]; const mockGithub = jest.fn(() => ({ @@ -446,7 +441,8 @@ describe('GithubUtils', () => { const internalQAHeader = '\r\n\r\n**Internal QA:**'; const lineBreak = '\r\n'; const lineBreakDouble = '\r\n\r\n'; - const assignOctocatHubot = ' - @octocat @hubot'; + const assignOctocat = ' - @octocat'; + const assignHubot = ' - @hubot'; const deployerVerificationsHeader = '\r\n**Deployer verifications:**'; // eslint-disable-next-line max-len const timingDashboardVerification = @@ -468,8 +464,8 @@ describe('GithubUtils', () => { `${lineBreak}`; test('Test no verified PRs', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList).then((issue) => { + expect(issue.issueBody).toBe( `${baseExpectedOutput}` + `${openCheckbox}${basePRList[2]}` + `${lineBreak}${openCheckbox}${basePRList[0]}` + @@ -482,12 +478,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test some verified PRs', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList, [basePRList[0]]).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList, [basePRList[0]]).then((issue) => { + expect(issue.issueBody).toBe( `${baseExpectedOutput}` + `${openCheckbox}${basePRList[2]}` + `${lineBreak}${closedCheckbox}${basePRList[0]}` + @@ -500,12 +497,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test all verified PRs', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList).then((issue) => { + expect(issue.issueBody).toBe( `${allVerifiedExpectedOutput}` + `${lineBreak}${deployerVerificationsHeader}` + `${lineBreak}${openCheckbox}${timingDashboardVerification}` + @@ -513,12 +511,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test no resolved deploy blockers', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList).then((issue) => { + expect(issue.issueBody).toBe( `${allVerifiedExpectedOutput}` + `${lineBreak}${deployBlockerHeader}` + `${lineBreak}${openCheckbox}${baseDeployBlockerList[0]}` + @@ -529,12 +528,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}${lineBreak}` + `${lineBreak}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test some resolved deploy blockers', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList, [baseDeployBlockerList[0]]).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList, [baseDeployBlockerList[0]]).then((issue) => { + expect(issue.issueBody).toBe( `${allVerifiedExpectedOutput}` + `${lineBreak}${deployBlockerHeader}` + `${lineBreak}${closedCheckbox}${baseDeployBlockerList[0]}` + @@ -545,12 +545,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test all resolved deploy blockers', () => { - githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList, baseDeployBlockerList).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, basePRList, basePRList, baseDeployBlockerList, baseDeployBlockerList).then((issue) => { + expect(issue.issueBody).toBe( `${baseExpectedOutput}` + `${closedCheckbox}${basePRList[2]}` + `${lineBreak}${closedCheckbox}${basePRList[0]}` + @@ -566,12 +567,13 @@ describe('GithubUtils', () => { `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual([]); }); }); test('Test internalQA PRs', () => { - githubUtils.generateStagingDeployCashBody(tag, [...basePRList, ...internalQAPRList]).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, [...basePRList, ...internalQAPRList]).then((issue) => { + expect(issue.issueBody).toBe( `${baseExpectedOutput}` + `${openCheckbox}${basePRList[2]}` + `${lineBreak}${openCheckbox}${basePRList[0]}` + @@ -579,20 +581,21 @@ describe('GithubUtils', () => { `${lineBreak}${closedCheckbox}${basePRList[4]}` + `${lineBreak}${closedCheckbox}${basePRList[5]}` + `${lineBreak}${internalQAHeader}` + - `${lineBreak}${openCheckbox}${internalQAPRList[0]}${assignOctocatHubot}` + - `${lineBreak}${openCheckbox}${internalQAPRList[1]}${assignOctocatHubot}` + + `${lineBreak}${openCheckbox}${internalQAPRList[0]}${assignOctocat}` + + `${lineBreak}${openCheckbox}${internalQAPRList[1]}${assignHubot}` + `${lineBreakDouble}${deployerVerificationsHeader}` + `${lineBreak}${openCheckbox}${timingDashboardVerification}` + `${lineBreak}${openCheckbox}${firebaseVerification}` + `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual(['octocat', 'hubot']); }); }); test('Test some verified internalQA PRs', () => { - githubUtils.generateStagingDeployCashBody(tag, [...basePRList, ...internalQAPRList], [], [], [], [internalQAPRList[0]]).then((issueBody: string) => { - expect(issueBody).toBe( + githubUtils.generateStagingDeployCashBody(tag, [...basePRList, ...internalQAPRList], [], [], [], [internalQAPRList[0]]).then((issue) => { + expect(issue.issueBody).toBe( `${baseExpectedOutput}` + `${openCheckbox}${basePRList[2]}` + `${lineBreak}${openCheckbox}${basePRList[0]}` + @@ -600,14 +603,15 @@ describe('GithubUtils', () => { `${lineBreak}${closedCheckbox}${basePRList[4]}` + `${lineBreak}${closedCheckbox}${basePRList[5]}` + `${lineBreak}${internalQAHeader}` + - `${lineBreak}${closedCheckbox}${internalQAPRList[0]}${assignOctocatHubot}` + - `${lineBreak}${openCheckbox}${internalQAPRList[1]}${assignOctocatHubot}` + + `${lineBreak}${closedCheckbox}${internalQAPRList[0]}${assignOctocat}` + + `${lineBreak}${openCheckbox}${internalQAPRList[1]}${assignHubot}` + `${lineBreakDouble}${deployerVerificationsHeader}` + `${lineBreak}${openCheckbox}${timingDashboardVerification}` + `${lineBreak}${openCheckbox}${firebaseVerification}` + `${lineBreak}${openCheckbox}${ghVerification}` + `${lineBreakDouble}${ccApplauseLeads}`, ); + expect(issue.issueAssignees).toEqual(['octocat', 'hubot']); }); }); }); diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 622881bc7979..79b6985ed94a 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -340,7 +340,7 @@ describe('libs/NextStepUtils', () => { return Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { submitsTo: currentUserAccountID, - preventSelfApprovalEnabled: true, + preventSelfApproval: true, }).then(() => { const result = NextStepUtils.buildNextStep(report, CONST.REPORT.STATUS_NUM.OPEN); diff --git a/tests/unit/ViolationUtilsTest.js b/tests/unit/ViolationUtilsTest.js index ff86b5fc6753..15a3a4f7de07 100644 --- a/tests/unit/ViolationUtilsTest.js +++ b/tests/unit/ViolationUtilsTest.js @@ -132,11 +132,6 @@ describe('getViolationsOnyxData', () => { Lunch: {name: 'Lunch', enabled: true}, Dinner: {name: 'Dinner', enabled: true}, }, - Tag: { - name: 'Tag', - required: true, - tags: {Lunch: {enabled: true}, Dinner: {enabled: true}}, - }, }, }; transaction.tag = 'Lunch'; @@ -201,4 +196,77 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toContainEqual([missingTagViolation]); }); }); + describe('policy has multi level tags', () => { + beforeEach(() => { + policyRequiresTags = true; + policyTags = { + Department: { + name: 'Department', + tags: { + Accounting: { + name: 'Accounting', + enabled: true, + }, + }, + required: true, + }, + Region: { + name: 'Region', + tags: { + Africa: { + name: 'Africa', + enabled: true, + }, + }, + }, + Project: { + name: 'Project', + tags: { + Project1: { + name: 'Project1', + enabled: true, + }, + }, + required: true, + }, + }; + }); + it('should return someTagLevelsRequired when a required tag is missing', () => { + const someTagLevelsRequiredViolation = { + name: 'someTagLevelsRequired', + type: 'violation', + data: { + errorIndexes: [0, 1, 2], + }, + }; + + // Test case where transaction has no tags + let result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual([someTagLevelsRequiredViolation]); + + // Test case where transaction has 1 tag + transaction.tag = 'Accounting'; + someTagLevelsRequiredViolation.data = {errorIndexes: [1, 2]}; + result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual([someTagLevelsRequiredViolation]); + + // Test case where transaction has 2 tags + transaction.tag = 'Accounting::Project1'; + someTagLevelsRequiredViolation.data = {errorIndexes: [1]}; + result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual([someTagLevelsRequiredViolation]); + + // Test case where transaction has all tags + transaction.tag = 'Accounting:Africa:Project1'; + result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + expect(result.value).toEqual([]); + }); + it('should return tagOutOfPolicy when a tag is not enabled in the policy but is set in the transaction', () => { + policyTags.Department.tags.Accounting.enabled = false; + transaction.tag = 'Accounting:Africa:Project1'; + const result = ViolationsUtils.getViolationsOnyxData(transaction, transactionViolations, policyRequiresTags, policyTags, policyRequiresCategories, policyCategories); + const violation = {...tagOutOfPolicyViolation, data: {tagName: 'Department'}}; + expect(result.value).toEqual([violation]); + }); + }); }); diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index 80f28002f975..85c2d67f80bc 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -260,7 +260,7 @@ function getFakePolicy(id = '1', name = 'Workspace-Test-001'): Policy { enabled: true, }, autoReportingOffset: 1, - preventSelfApprovalEnabled: true, + preventSelfApproval: true, submitsTo: 123456, defaultBillable: false, disabledFields: {defaultBillable: true, reimbursable: false}, diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index ba5108d49481..8dd04f4750a9 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -14,7 +14,7 @@ export default function createRandomPolicy(index: number): Policy { enabled: randBoolean(), }, autoReportingOffset: 1, - preventSelfApprovalEnabled: randBoolean(), + preventSelfApproval: randBoolean(), submitsTo: index, outputCurrency: randCurrencyCode(), role: rand(Object.values(CONST.POLICY.ROLE)),