From c03786570e7b93cfbee9a0220deecc2f1c6a9ac4 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Tue, 28 Nov 2023 16:07:32 +0100 Subject: [PATCH 001/205] Rename Policy file --- src/libs/actions/{Policy.js => Policy.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/libs/actions/{Policy.js => Policy.ts} (100%) diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.ts similarity index 100% rename from src/libs/actions/Policy.js rename to src/libs/actions/Policy.ts From ddaf7b98934418ac7e9bb1db33aaad9401020b01 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Thu, 30 Nov 2023 14:29:53 +0100 Subject: [PATCH 002/205] TS migration --- src/libs/PersonalDetailsUtils.js | 8 +- src/libs/ReportUtils.ts | 2 +- src/libs/actions/Policy.ts | 626 ++++++++++---------- src/pages/workspace/WorkspaceMembersPage.js | 2 + src/types/onyx/Policy.ts | 56 +- src/types/onyx/Report.ts | 7 +- 6 files changed, 369 insertions(+), 332 deletions(-) diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index 560480dcec9d..ec7efa7ffa28 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -79,7 +79,7 @@ function getAccountIDsByLogins(logins) { /** * Given a list of accountIDs, find the associated personal detail and return related logins. * - * @param {Array} accountIDs Array of user accountIDs + * @param {Array} accountIDs Array of user accountIDs * @returns {Array} - Array of logins according to passed accountIDs */ function getLoginsByAccountIDs(accountIDs) { @@ -101,7 +101,11 @@ function getLoginsByAccountIDs(accountIDs) { * * @param {Array} logins Array of user logins * @param {Array} accountIDs Array of user accountIDs - * @returns {Object} - Object with optimisticData, successData and failureData (object of personal details objects) + * @typedef {Object} OnyxData + * @property {Array} optimisticData + * @property {Array} successData + * @property {Array} failureData + * @returns {OnyxData} - Object with optimisticData, successData and failureData (object of personal details objects) */ function getNewPersonalDetailsOnyxData(logins, accountIDs) { const optimisticData = {}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d93661778b83..66207bcd741c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4383,4 +4383,4 @@ export { canEditWriteCapability, }; -export type {OptionData}; +export type {OptionData, OptimisticClosedReportAction}; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index bcc371b3a609..200eb00f4822 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -1,11 +1,10 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; -import filter from 'lodash/filter'; -import lodashGet from 'lodash/get'; +import lodashClone from 'lodash/clone'; import lodashUnion from 'lodash/union'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import Onyx, {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import {OnyxEntry} from 'react-native-onyx/lib/types'; import * as API from '@libs/API'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -15,11 +14,16 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import {OptimisticClosedReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {Network, PersonalDetails, Policy, PolicyMember, RecentlyUsedCategories, ReimbursementAccount, Report, ReportAction, Transaction} from '@src/types/onyx'; +import {Errors} from '@src/types/onyx/OnyxCommon'; +import {CustomUnit, NewCustomUnit} from '@src/types/onyx/Policy'; +import {isNotEmptyObject} from '@src/types/utils/EmptyObject'; -const allPolicies = {}; +const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, callback: (val, key) => { @@ -34,7 +38,11 @@ Onyx.connect({ const policyReports = ReportUtils.getAllPolicyReports(policyID); const cleanUpMergeQueries = {}; const cleanUpSetQueries = {}; - _.each(policyReports, ({reportID}) => { + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; cleanUpMergeQueries[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = {hasDraft: false}; cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; @@ -49,14 +57,19 @@ Onyx.connect({ }, }); -let allPolicyMembers; +let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, waitForCollectionCallback: true, - callback: (val) => (allPolicyMembers = val), + // callback: (val) => (allPolicyMembers = val), + callback: (val) => { + // console.log('POLICYMEMBERS', val); + // console.log('TYPEPOLICYMEMBERS', typeof Object.keys(val)[0]); + allPolicyMembers = val; + }, }); -let lastAccessedWorkspacePolicyID = null; +let lastAccessedWorkspacePolicyID: OnyxEntry = null; Onyx.connect({ key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, callback: (value) => (lastAccessedWorkspacePolicyID = value), @@ -67,67 +80,63 @@ let sessionAccountID = 0; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - sessionEmail = lodashGet(val, 'email', ''); - sessionAccountID = lodashGet(val, 'accountID', 0); + sessionEmail = val?.email ?? ''; + sessionAccountID = val?.accountID ?? 0; }, }); -let allPersonalDetails; +let allPersonalDetails: OnyxEntry>; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => (allPersonalDetails = val), }); -let reimbursementAccount; +let reimbursementAccount: OnyxEntry; Onyx.connect({ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, callback: (val) => (reimbursementAccount = val), }); -let allRecentlyUsedCategories = {}; +let allRecentlyUsedCategories: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, waitForCollectionCallback: true, callback: (val) => (allRecentlyUsedCategories = val), }); -let networkStatus = {}; +let networkStatus: OnyxEntry = {}; Onyx.connect({ key: ONYXKEYS.NETWORK, - waitForCollectionCallback: true, callback: (val) => (networkStatus = val), }); /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user - * @param {String|null} policyID */ -function updateLastAccessedWorkspace(policyID) { +function updateLastAccessedWorkspace(policyID: OnyxEntry) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } /** * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} + */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); +function hasActiveFreePolicy(policies: Array> | Record>): boolean { + const adminFreePolicies = Object.values(policies).filter((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); if (adminFreePolicies.length === 0) { return false; } - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + if (adminFreePolicies.some((policy) => !policy?.pendingAction)) { return true; } - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { return true; } - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + if (adminFreePolicies.some((policy) => policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { return false; } @@ -138,14 +147,14 @@ function hasActiveFreePolicy(policies) { /** * Delete the workspace - * - * @param {String} policyID - * @param {Array} reports - * @param {String} policyName */ -function deleteWorkspace(policyID, reports, policyName) { - const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); - const optimisticData = [ +function deleteWorkspace(policyID: string, reports: Report[], policyName: string) { + if (!allPolicies) { + return; + } + + const filteredPolicies = Object.values(allPolicies).filter((policy) => policy?.id !== policyID); + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -154,32 +163,32 @@ function deleteWorkspace(policyID, reports, policyName) { errors: null, }, }, - ..._.map(reports, ({reportID}) => ({ + ...reports.map(({reportID}) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS.CLOSED, hasDraft: false, - oldPolicyName: allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`].name, + oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name, }, })), - ..._.map(reports, ({reportID}) => ({ + ...reports.map(({reportID}) => ({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, value: null, })), // Add closed actions to all chat reports linked to this policy - ..._.map(reports, ({reportID, ownerAccountID}) => { + ...reports.map(({reportID, ownerAccountID}) => { // Announce & admin chats have FAKE owners, but workspace chats w/ users do have owners. - let emailClosingReport = CONST.POLICY.OWNER_EMAIL_FAKE; - if (ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { - emailClosingReport = lodashGet(allPersonalDetails, [ownerAccountID, 'login'], ''); + let emailClosingReport: string = CONST.POLICY.OWNER_EMAIL_FAKE; + if (!!ownerAccountID && ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { + emailClosingReport = allPersonalDetails?.[ownerAccountID]?.login ?? ''; } const optimisticClosedReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); - const optimisticReportActions = {}; + const optimisticReportActions: Record = {}; optimisticReportActions[optimisticClosedReportAction.reportActionID] = optimisticClosedReportAction; return { onyxMethod: Onyx.METHOD.MERGE, @@ -202,8 +211,8 @@ function deleteWorkspace(policyID, reports, policyName) { ]; // Restore the old report stateNum and statusNum - const failureData = [ - ..._.map(reports, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => ({ + const failureData: OnyxUpdate[] = [ + ...reports.map(({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { @@ -217,14 +226,14 @@ function deleteWorkspace(policyID, reports, policyName) { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { - errors: lodashGet(reimbursementAccount, 'errors', null), + errors: reimbursementAccount?.errors ?? null, }, }, ]; // We don't need success data since the push notification will update // the onyxData for all connected clients. - const successData = []; + const successData: OnyxUpdate[] = []; API.write('DeleteWorkspace', {policyID}, {optimisticData, successData, failureData}); // Reset the lastAccessedWorkspacePolicyID @@ -235,84 +244,87 @@ function deleteWorkspace(policyID, reports, policyName) { /** * Is the user an admin of a free policy (aka workspace)? - * - * @param {Record} [policies] - * @returns {Boolean} */ -function isAdminOfFreePolicy(policies) { - return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); +function isAdminOfFreePolicy(policies: Record): boolean { + return Object.values(policies).some((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } +type AnnounceRoomMembers = { + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; +}; + /** - * Build optimistic data for adding members to the announce room - * @param {String} policyID - * @param {Array} accountIDs - * @returns {Object} + * Build optimistic data for adding members to the announcement room */ -function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { +function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[]) { const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); - const announceRoomMembers = { + const announceRoomMembers: AnnounceRoomMembers = { onyxOptimisticData: [], onyxFailureData: [], }; - announceRoomMembers.onyxOptimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs: [...announceReport.participantAccountIDs, ...accountIDs], - }, - }); + if (announceReport?.participantAccountIDs) { + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, + value: { + participantAccountIDs: [...announceReport.participantAccountIDs, ...accountIDs], + }, + }); + } announceRoomMembers.onyxFailureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport?.reportID}`, value: { - participantAccountIDs: announceReport.participantAccountIDs, + participantAccountIDs: announceReport?.participantAccountIDs, }, }); return announceRoomMembers; } +type OptimisticAnnounceRoomMembers = { + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; +}; + /** - * Build optimistic data for removing users from the announce room - * @param {String} policyID - * @param {Array} accountIDs - * @returns {Object} + * Build optimistic data for removing users from the announcement room */ -function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { +function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: string[]) { const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); - const announceRoomMembers = { + const announceRoomMembers: OptimisticAnnounceRoomMembers = { onyxOptimisticData: [], onyxFailureData: [], }; - const remainUsers = _.difference(announceReport.participantAccountIDs, accountIDs); - announceRoomMembers.onyxOptimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs: [...remainUsers], - }, - }); + if (announceReport?.participantAccountIDs) { + const remainUsers = announceReport.participantAccountIDs.filter((e) => !accountIDs.includes(e)); + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...remainUsers], + }, + }); + + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + }, + }); + } - announceRoomMembers.onyxFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, - value: { - participantAccountIDs: announceReport.participantAccountIDs, - }, - }); return announceRoomMembers; } /** * Remove the passed members from the policy employeeList - * - * @param {Array} accountIDs - * @param {String} policyID */ -function removeMembers(accountIDs, policyID) { +function removeMembers(accountIDs: string[], policyID: string) { // In case user selects only themselves (admin), their email will be filtered out and the members // array passed will be empty, prevent the function from proceeding in that case as there is no one to remove if (accountIDs.length === 0) { @@ -322,21 +334,28 @@ function removeMembers(accountIDs, policyID) { const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`; const policy = ReportUtils.getPolicy(policyID); const workspaceChats = ReportUtils.getWorkspaceChats(policyID, accountIDs); - const optimisticClosedReportActions = _.map(workspaceChats, () => - ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY), - ); + const optimisticClosedReportActions = workspaceChats.map(() => ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY)); const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policyID, accountIDs); + const optimisticMembersState: Record = {}; + const successMembersState: Record = {}; + const failureMembersState: Record = {}; + accountIDs.forEach((accountID) => { + optimisticMembersState[accountID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}; + successMembersState[accountID] = null; + failureMembersState[accountID] = {errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')}; + }); + const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})), + value: optimisticMembersState, }, - ..._.map(workspaceChats, (report) => ({ + ...workspaceChats.map((report) => ({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${report?.reportID}`, value: { statusNum: CONST.REPORT.STATUS.CLOSED, stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, @@ -344,9 +363,9 @@ function removeMembers(accountIDs, policyID) { hasDraft: false, }, })), - ..._.map(optimisticClosedReportActions, (reportAction, index) => ({ + ...optimisticClosedReportActions.map((reportAction, index) => ({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, value: {[reportAction.reportActionID]: reportAction}, })), ...announceRoomMembers.onyxOptimisticData, @@ -354,15 +373,20 @@ function removeMembers(accountIDs, policyID) { // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins. // If we delete all these logins then we should clear the informative messages since they are no longer relevant. - if (!_.isEmpty(policy.primaryLoginsInvited)) { + if (isNotEmptyObject(policy?.primaryLoginsInvited ?? {})) { // Take the current policy members and remove them optimistically - const policyMemberAccountIDs = _.map(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`], (value, key) => Number(key)); - const remainingMemberAccountIDs = _.difference(policyMemberAccountIDs, accountIDs); - const remainingLogins = PersonalDetailsUtils.getLoginsByAccountIDs(remainingMemberAccountIDs); - const invitedPrimaryToSecondaryLogins = _.invert(policy.primaryLoginsInvited); + // console.log('POLICYMEMBERS', allPolicyMembers); + const policyMemberAccountIDs = Object.keys(allPolicyMembers?.[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`] ?? {}); + const remainingMemberAccountIDs = policyMemberAccountIDs.filter((e) => !accountIDs.includes(e)); + const remainingLogins: string[] = PersonalDetailsUtils.getLoginsByAccountIDs(remainingMemberAccountIDs); + const invitedPrimaryToSecondaryLogins: Record = {}; + + if (policy.primaryLoginsInvited) { + Object.keys(policy.primaryLoginsInvited).forEach((key) => (invitedPrimaryToSecondaryLogins[policy.primaryLoginsInvited?.[key] ?? ''] = key)); + } // Then, if no remaining members exist that were invited by a secondary login, clear the informative messages - if (!_.some(remainingLogins, (remainingLogin) => Boolean(invitedPrimaryToSecondaryLogins[remainingLogin]))) { + if (!remainingLogins.some((remainingLogin) => Boolean(invitedPrimaryToSecondaryLogins[remainingLogin]))) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -377,16 +401,18 @@ function removeMembers(accountIDs, policyID) { { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill(null)), + value: successMembersState, }, ]; + + const filteredWorkspaceChats = workspaceChats.filter((e) => e !== null) as Report[]; const failureData = [ { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(accountIDs, Array(accountIDs.length).fill({errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')})), + value: failureMembersState, }, - ..._.map(workspaceChats, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => ({ + ...filteredWorkspaceChats.map(({reportID, stateNum, statusNum, hasDraft, oldPolicyName = null}) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { @@ -396,9 +422,9 @@ function removeMembers(accountIDs, policyID) { oldPolicyName, }, })), - ..._.map(optimisticClosedReportActions, (reportAction, index) => ({ + ...optimisticClosedReportActions.map((reportAction, index) => ({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats?.[index]?.reportID}`, value: {[reportAction.reportActionID]: null}, })), ...announceRoomMembers.onyxFailureData, @@ -406,30 +432,41 @@ function removeMembers(accountIDs, policyID) { API.write( 'DeleteMembersFromWorkspace', { - emailList: _.map(accountIDs, (accountID) => allPersonalDetails[accountID].login).join(','), + emailList: accountIDs.map((accountID) => allPersonalDetails?.[accountID].login).join(','), policyID, }, {optimisticData, successData, failureData}, ); } +type WorkspaceMembersChats = { + onyxSuccessData: OnyxUpdate[]; + onyxOptimisticData: OnyxUpdate[]; + onyxFailureData: OnyxUpdate[]; + reportCreationData: Record< + string, + { + reportID: string; + reportActionID?: string; + } + >; +}; + /** * Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx. * - * @param {String} policyID - * @param {Object} invitedEmailsToAccountIDs - * @param {Boolean} hasOutstandingChildRequest - * @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) + * @returns - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) */ -function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutstandingChildRequest = false) { - const workspaceMembersChats = { +function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: Record, hasOutstandingChildRequest = false) { + const workspaceMembersChats: WorkspaceMembersChats = { onyxSuccessData: [], onyxOptimisticData: [], onyxFailureData: [], reportCreationData: {}, }; - _.each(invitedEmailsToAccountIDs, (accountID, email) => { + Object.keys(invitedEmailsToAccountIDs).forEach((email) => { + const accountID = invitedEmailsToAccountIDs[email]; const cleanAccountID = Number(accountID); const login = OptionsListUtils.addSMSDomainIfPhoneNumber(email); @@ -473,7 +510,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutsta workspaceMembersChats.onyxOptimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`, - value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction}, + value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction as ReportAction}, }); workspaceMembersChats.onyxSuccessData.push({ @@ -508,15 +545,10 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutsta /** * Adds members to the specified workspace/policyID - * - * @param {Object} invitedEmailsToAccountIDs - * @param {String} welcomeNote - * @param {String} policyID */ -function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) { - const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`; - const logins = _.map(_.keys(invitedEmailsToAccountIDs), (memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); - const accountIDs = _.values(invitedEmailsToAccountIDs); +function addMembersToWorkspace(invitedEmailsToAccountIDs: Record, welcomeNote: string, policyID: string) { + const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin)); + const accountIDs = Object.values(invitedEmailsToAccountIDs); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs); const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs); @@ -524,76 +556,80 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); - const optimisticData = [ + const optimisticMembersState: Record = {}; + const failureMembersState: Record = {}; + accountIDs.forEach((accountID) => { + optimisticMembersState[accountID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}; + failureMembersState[accountID] = { + errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), + }; + }); + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: membersListKey, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, // Convert to object with each key containing {pendingAction: ‘add’} - value: _.object(accountIDs, Array(accountIDs.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD})), + value: optimisticMembersState, }, - ...newPersonalDetailsOnyxData.optimisticData, + ...(newPersonalDetailsOnyxData.optimisticData as OnyxUpdate[]), ...membersChats.onyxOptimisticData, ...announceRoomMembers.onyxOptimisticData, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: membersListKey, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, // Convert to object with each key clearing pendingAction, when it is an existing account. // Remove the object, when it is a newly created account. - value: _.reduce( - accountIDs, - (accountIDsWithClearedPendingAction, accountID) => { - let value = null; - const accountAlreadyExists = !_.isEmpty(allPersonalDetails[accountID]); + value: accountIDs.reduce((accountIDsWithClearedPendingAction, accountID) => { + let value = null; + const accountAlreadyExists = isNotEmptyObject(allPersonalDetails?.[accountID]); - if (accountAlreadyExists) { - value = {pendingAction: null, errors: null}; - } + if (accountAlreadyExists) { + value = {pendingAction: null, errors: null}; + } - // eslint-disable-next-line no-param-reassign - accountIDsWithClearedPendingAction[accountID] = value; - - return accountIDsWithClearedPendingAction; - }, - {}, - ), + return {...accountIDsWithClearedPendingAction, [accountID]: value}; + }, {}), }, - ...newPersonalDetailsOnyxData.successData, + ...(newPersonalDetailsOnyxData.successData as OnyxUpdate[]), ...membersChats.onyxSuccessData, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, - key: membersListKey, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, // Convert to object with each key containing the error. We don’t // need to remove the members since that is handled by onClose of OfflineWithFeedback. - value: _.object( - accountIDs, - Array(accountIDs.length).fill({ - errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), - }), - ), + value: failureMembersState, }, - ...newPersonalDetailsOnyxData.failureData, + ...(newPersonalDetailsOnyxData.failureData as OnyxUpdate[]), ...membersChats.onyxFailureData, ...announceRoomMembers.onyxFailureData, ]; - const params = { - employees: JSON.stringify(_.map(logins, (login) => ({email: login}))), + type AddMembersToWorkspaceParams = { + employees: string; + welcomeNote: string; + policyID: string; + reportCreationData?: string; + }; + + const params: AddMembersToWorkspaceParams = { + employees: JSON.stringify(logins.map((login) => ({email: login}))), // Do not escape HTML special chars for welcomeNote as this will be handled in the backend. // See https://github.com/Expensify/App/issues/20081 for more details. welcomeNote, policyID, }; - if (!_.isEmpty(membersChats.reportCreationData)) { + if (isNotEmptyObject(membersChats.reportCreationData)) { params.reportCreationData = JSON.stringify(membersChats.reportCreationData); } API.write('AddMembersToWorkspace', params, {optimisticData, successData, failureData}); @@ -601,12 +637,9 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) /** * Updates a workspace avatar image - * - * @param {String} policyID - * @param {File|Object} file */ -function updateWorkspaceAvatar(policyID, file) { - const optimisticData = [ +function updateWorkspaceAvatar(policyID: string, file: File) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -622,7 +655,7 @@ function updateWorkspaceAvatar(policyID, file) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -633,12 +666,12 @@ function updateWorkspaceAvatar(policyID, file) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - avatar: allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`].avatar, + avatar: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatar, pendingFields: { avatar: null, }, @@ -651,10 +684,9 @@ function updateWorkspaceAvatar(policyID, file) { /** * Deletes the avatar image for the workspace - * @param {String} policyID */ -function deleteWorkspaceAvatar(policyID) { - const optimisticData = [ +function deleteWorkspaceAvatar(policyID: string) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -669,7 +701,7 @@ function deleteWorkspaceAvatar(policyID) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -680,7 +712,7 @@ function deleteWorkspaceAvatar(policyID) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -699,9 +731,8 @@ function deleteWorkspaceAvatar(policyID) { /** * Clear error and pending fields for the workspace avatar - * @param {String} policyID */ -function clearAvatarErrors(policyID) { +function clearAvatarErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { errorFields: { avatar: null, @@ -715,22 +746,28 @@ function clearAvatarErrors(policyID) { /** * Optimistically update the general settings. Set the general settings as pending until the response succeeds. * If the response fails set a general error message. Clear the error message when updating. - * - * @param {String} policyID - * @param {String} name - * @param {String} currency */ -function updateGeneralSettings(policyID, name, currency) { - const policy = allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const distanceUnit = _.find(_.values(policy.customUnits), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceRate = _.find(_.values(distanceUnit ? distanceUnit.rates : {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const optimisticData = [ +function updateGeneralSettings(policyID: string, name: string, currency: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + if (!policy) { + return; + } + + const distanceUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceRate = Object.values(distanceUnit ? distanceUnit.rates : {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + if (!distanceUnit?.customUnitID || !distanceRate?.customUnitRateID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ { // We use SET because it's faster than merge and avoids a race condition when setting the currency and navigating the user to the Bank account page in confirmCurrencyChangeAndHideModal onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - ...allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], + ...((allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}) as Policy), pendingFields: { generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -742,13 +779,13 @@ function updateGeneralSettings(policyID, name, currency) { }, name, outputCurrency: currency, - ...(distanceUnit + ...((distanceUnit ? { customUnits: { [distanceUnit.customUnitID]: { ...distanceUnit, rates: { - [distanceRate.customUnitRateID]: { + [distanceRate?.customUnitRateID]: { ...distanceRate, currency, }, @@ -756,11 +793,11 @@ function updateGeneralSettings(policyID, name, currency) { }, }, } - : {}), + : {}) as Record), }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -771,7 +808,7 @@ function updateGeneralSettings(policyID, name, currency) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -805,9 +842,9 @@ function updateGeneralSettings(policyID, name, currency) { } /** - * @param {String} policyID The id of the workspace / policy + * @param policyID The id of the workspace / policy */ -function clearWorkspaceGeneralSettingsErrors(policyID) { +function clearWorkspaceGeneralSettingsErrors(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { errorFields: { generalSettings: null, @@ -815,12 +852,8 @@ function clearWorkspaceGeneralSettingsErrors(policyID) { }); } -/** - * @param {String} policyID - * @param {Object} errors - */ -function setWorkspaceErrors(policyID, errors) { - if (!allPolicies[policyID]) { +function setWorkspaceErrors(policyID: string, errors: Errors) { + if (!allPolicies?.[policyID]) { return; } @@ -828,12 +861,7 @@ function setWorkspaceErrors(policyID, errors) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errors}); } -/** - * @param {String} policyID - * @param {String} customUnitID - * @param {String} customUnitRateID - */ -function clearCustomUnitErrors(policyID, customUnitID, customUnitRateID) { +function clearCustomUnitErrors(policyID: string, customUnitID: string, customUnitRateID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { customUnits: { [customUnitID]: { @@ -850,25 +878,21 @@ function clearCustomUnitErrors(policyID, customUnitID, customUnitRateID) { }); } -/** - * @param {String} policyID - */ -function hideWorkspaceAlertMessage(policyID) { - if (!allPolicies[policyID]) { +function hideWorkspaceAlertMessage(policyID: string) { + if (!allPolicies?.[policyID]) { return; } Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -/** - * @param {String} policyID - * @param {Object} currentCustomUnit - * @param {Object} newCustomUnit - * @param {Number} lastModified - */ -function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustomUnit, lastModified) { - const optimisticData = [ +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified: number) { + // console.log('CUSTOMRATES', newCustomUnit, currentCustomUnit); + if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID || !currentCustomUnit.rates?.customUnitRateID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -890,7 +914,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -910,7 +934,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -930,13 +954,13 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom }, ]; - const newCustomUnitParam = _.clone(newCustomUnit); - newCustomUnitParam.rates = _.omit(newCustomUnitParam.rates, ['pendingAction', 'errors']); + const newCustomUnitParam = lodashClone(newCustomUnit); + newCustomUnitParam.rates = {...newCustomUnitParam.rates, pendingAction: undefined, errors: undefined}; API.write( 'UpdateWorkspaceCustomUnitAndRate', { policyID, - ...(!networkStatus.isOffline && {lastModified}), + ...(!networkStatus?.isOffline && {lastModified}), customUnit: JSON.stringify(newCustomUnitParam), customUnitRate: JSON.stringify(newCustomUnitParam.rates), }, @@ -946,11 +970,8 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom /** * Removes an error after trying to delete a member - * - * @param {String} policyID - * @param {Number} accountID */ -function clearDeleteMemberError(policyID, accountID) { +function clearDeleteMemberError(policyID: string, accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, { [accountID]: { pendingAction: null, @@ -961,11 +982,8 @@ function clearDeleteMemberError(policyID, accountID) { /** * Removes an error after trying to add a member - * - * @param {String} policyID - * @param {Number} accountID */ -function clearAddMemberError(policyID, accountID) { +function clearAddMemberError(policyID: string, accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, { [accountID]: null, }); @@ -976,10 +994,8 @@ function clearAddMemberError(policyID, accountID) { /** * Removes an error after trying to delete a workspace - * - * @param {String} policyID */ -function clearDeleteWorkspaceError(policyID) { +function clearDeleteWorkspaceError(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { pendingAction: null, errors: null, @@ -988,19 +1004,16 @@ function clearDeleteWorkspaceError(policyID) { /** * Removes the workspace after failure to create. - * - * @param {String} policyID */ -function removeWorkspace(policyID) { +function removeWorkspace(policyID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); } /** * Generate a policy name based on an email and policy list. - * @param {String} [email] the email to base the workspace name on. If not passed, will use the logged in user's email instead - * @returns {String} + * @param [email] the email to base the workspace name on. If not passed, will use the logged-in user's email instead */ -function generateDefaultWorkspaceName(email = '') { +function generateDefaultWorkspaceName(email = ''): string { const emailParts = email ? email.split('@') : sessionEmail.split('@'); let defaultWorkspaceName = ''; if (!emailParts || emailParts.length !== 2) { @@ -1009,7 +1022,7 @@ function generateDefaultWorkspaceName(email = '') { const username = emailParts[0]; const domain = emailParts[1]; - if (_.includes(PUBLIC_DOMAINS, domain.toLowerCase())) { + if (PUBLIC_DOMAINS.includes(domain.toLowerCase() as (typeof PUBLIC_DOMAINS)[number])) { defaultWorkspaceName = `${Str.UCFirst(username)}'s Workspace`; } else { defaultWorkspaceName = `${Str.UCFirst(domain.split('.')[0])}'s Workspace`; @@ -1019,43 +1032,37 @@ function generateDefaultWorkspaceName(email = '') { defaultWorkspaceName = 'My Group Workspace'; } - if (allPolicies.length === 0) { + if (!isNotEmptyObject(allPolicies ?? {})) { return defaultWorkspaceName; } // find default named workspaces and increment the last number const numberRegEx = new RegExp(`${escapeRegExp(defaultWorkspaceName)} ?(\\d*)`, 'i'); - const lastWorkspaceNumber = _.chain(allPolicies) - .filter((policy) => policy.name && numberRegEx.test(policy.name)) - .map((policy) => parseInt(numberRegEx.exec(policy.name)[1] || 1, 10)) // parse the number at the end - .max() - .value(); + const parsedWorkspaceNumbers = Object.values(allPolicies ?? {}) + .filter((policy) => policy?.name && numberRegEx.test(policy.name)) + .map((policy) => parseInt(numberRegEx.exec(policy?.name ?? '')?.[1] ?? '1', 10)); // parse the number at the end + const lastWorkspaceNumber = Math.max(...parsedWorkspaceNumbers); return lastWorkspaceNumber !== -Infinity ? `${defaultWorkspaceName} ${lastWorkspaceNumber + 1}` : defaultWorkspaceName; } /** * Returns a client generated 16 character hexadecimal value for the policyID - * @returns {String} */ -function generatePolicyID() { +function generatePolicyID(): string { return NumberUtils.generateHexadecimalValue(16); } /** * Returns a client generated 13 character hexadecimal value for a custom unit ID - * @returns {String} */ -function generateCustomUnitID() { +function generateCustomUnitID(): string { return NumberUtils.generateHexadecimalValue(13); } -/** - * @returns {Object} - */ function buildOptimisticCustomUnits() { const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); - const customUnits = { + const customUnits: Record = { [customUnitID]: { customUnitID, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, @@ -1082,16 +1089,16 @@ function buildOptimisticCustomUnits() { /** * Optimistically creates a Policy Draft for a new workspace * - * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy - * @param {String} [policyName] Optional, custom policy name we will use for created workspace - * @param {String} [policyID] Optional, custom policy id we will use for created workspace - * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy + * @param [policyOwnerEmail] Optional, the email of the account to make the owner of the policy + * @param [policyName] Optional, custom policy name we will use for created workspace + * @param [policyID] Optional, custom policy id we will use for created workspace + * @param [makeMeAdmin] Optional, leave the calling account as an admin on the policy */ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits} = buildOptimisticCustomUnits(); - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, @@ -1102,7 +1109,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol role: CONST.POLICY.ROLE.ADMIN, owner: sessionEmail, isPolicyExpenseChatEnabled: true, - outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD), + outputCurrency: allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, customUnits, makeMeAdmin, @@ -1126,13 +1133,12 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol /** * Optimistically creates a new workspace and default workspace chats * - * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy - * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy - * @param {String} [policyName] Optional, custom policy name we will use for created workspace - * @param {String} [policyID] Optional, custom policy id we will use for created workspace - * @returns {String} + * @param [policyOwnerEmail] Optional, the email of the account to make the owner of the policy + * @param [makeMeAdmin] Optional, leave the calling account as an admin on the policy + * @param [policyName] Optional, custom policy name we will use for created workspace + * @param [policyID] Optional, custom policy id we will use for created workspace */ -function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()) { +function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): string { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(); @@ -1181,7 +1187,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName role: CONST.POLICY.ROLE.ADMIN, owner: sessionEmail, isPolicyExpenseChatEnabled: true, - outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD), + outputCurrency: allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, customUnits, }, @@ -1272,7 +1278,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: { - [_.keys(announceChatData)[0]]: { + [Object.keys(announceChatData)[0]]: { pendingAction: null, }, }, @@ -1291,7 +1297,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: { - [_.keys(adminsChatData)[0]]: { + [Object.keys(adminsChatData)[0]]: { pendingAction: null, }, }, @@ -1310,7 +1316,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, value: { - [_.keys(expenseChatData)[0]]: { + [Object.keys(expenseChatData)[0]]: { pendingAction: null, }, }, @@ -1359,11 +1365,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName return adminsChatReportID; } -/** - * - * @param {string} policyID - */ -function openWorkspaceReimburseView(policyID) { +function openWorkspaceReimburseView(policyID: string) { if (!policyID) { Log.warn('openWorkspaceReimburseView invalid params', {policyID}); return; @@ -1392,7 +1394,7 @@ function openWorkspaceReimburseView(policyID) { API.read('OpenWorkspaceReimburseView', {policyID}, onyxData); } -function openWorkspaceMembersPage(policyID, clientMemberEmails) { +function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails}); return; @@ -1404,7 +1406,7 @@ function openWorkspaceMembersPage(policyID, clientMemberEmails) { }); } -function openWorkspaceInvitePage(policyID, clientMemberEmails) { +function openWorkspaceInvitePage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); return; @@ -1416,57 +1418,36 @@ function openWorkspaceInvitePage(policyID, clientMemberEmails) { }); } -/** - * @param {String} policyID - */ -function openDraftWorkspaceRequest(policyID) { +function openDraftWorkspaceRequest(policyID: string) { API.read('OpenDraftWorkspaceRequest', {policyID}); } -/** - * @param {String} policyID - * @param {Object} invitedEmailsToAccountIDs - */ -function setWorkspaceInviteMembersDraft(policyID, invitedEmailsToAccountIDs) { +function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccountIDs: Record) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, invitedEmailsToAccountIDs); } -/** - * @param {String} policyID - * @param {String} message - */ -function setWorkspaceInviteMessageDraft(policyID, message) { +function setWorkspaceInviteMessageDraft(policyID: string, message: string) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT}${policyID}`, message); } -/** - * @param {String} policyID - */ -function clearErrors(policyID) { +function clearErrors(policyID: string) { setWorkspaceErrors(policyID, {}); hideWorkspaceAlertMessage(policyID); } /** * Dismiss the informative messages about which policy members were added with primary logins when invited with their secondary login. - * - * @param {String} policyID */ -function dismissAddedWithPrimaryLoginMessages(policyID) { +function dismissAddedWithPrimaryLoginMessages(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {primaryLoginsInvited: null}); } -/** - * @param {String} policyID - * @param {String} category - * @returns {Object} - */ -function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) { +function buildOptimisticPolicyRecentlyUsedCategories(policyID: string, category: string) { if (!policyID || !category) { return []; } - const policyRecentlyUsedCategories = lodashGet(allRecentlyUsedCategories, `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`, []); + const policyRecentlyUsedCategories = allRecentlyUsedCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`] ?? []; return lodashUnion([category], policyRecentlyUsedCategories); } @@ -1476,10 +1457,9 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) { * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report. * - * @param {Object} iouReport - * @returns {String} policyID of the workspace we have created + * @returns policyID of the workspace we have created */ -function createWorkspaceFromIOUPayment(iouReport) { +function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { // This flow only works for IOU reports if (!ReportUtils.isIOUReport(iouReport)) { return; @@ -1509,6 +1489,10 @@ function createWorkspaceFromIOUPayment(iouReport) { expenseCreatedReportActionID: workspaceChatCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + if (!employeeAccountID || !employeeEmail) { + return; + } + // Create the workspace chat for the employee whose IOU is being paid const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true); const newWorkspace = { @@ -1527,7 +1511,7 @@ function createWorkspaceFromIOUPayment(iouReport) { customUnits, }; - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1560,7 +1544,7 @@ function createWorkspaceFromIOUPayment(iouReport) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: announceReportActionData, + value: announceReportActionData as Record, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1575,7 +1559,7 @@ function createWorkspaceFromIOUPayment(iouReport) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: adminsReportActionData, + value: adminsReportActionData as Record, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1590,7 +1574,7 @@ function createWorkspaceFromIOUPayment(iouReport) { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, - value: workspaceChatReportActionData, + value: workspaceChatReportActionData as Record, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1605,7 +1589,7 @@ function createWorkspaceFromIOUPayment(iouReport) { ...employeeWorkspaceChat.onyxOptimisticData, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1625,7 +1609,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: { - [_.keys(announceChatData)[0]]: { + [Object.keys(announceChatData)[0]]: { pendingAction: null, }, }, @@ -1644,7 +1628,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: { - [_.keys(adminsChatData)[0]]: { + [Object.keys(adminsChatData)[0]]: { pendingAction: null, }, }, @@ -1663,7 +1647,7 @@ function createWorkspaceFromIOUPayment(iouReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, value: { - [_.keys(workspaceChatData)[0]]: { + [Object.keys(workspaceChatData)[0]]: { pendingAction: null, }, }, @@ -1671,7 +1655,7 @@ function createWorkspaceFromIOUPayment(iouReport) { ...employeeWorkspaceChat.onyxSuccessData, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, @@ -1732,7 +1716,7 @@ function createWorkspaceFromIOUPayment(iouReport) { policyID, policyName: workspaceName, type: CONST.REPORT.TYPE.EXPENSE, - total: -iouReport.total, + total: -(iouReport?.total ?? 0), }; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -1749,9 +1733,9 @@ function createWorkspaceFromIOUPayment(iouReport) { const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID); // For performance reasons, we are going to compose a merge collection data for transactions - const transactionsOptimisticData = {}; - const transactionFailureData = {}; - _.each(reportTransactions, (transaction) => { + const transactionsOptimisticData: Record = {}; + const transactionFailureData: Record = {}; + reportTransactions.forEach((transaction) => { transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { ...transaction, amount: -transaction.amount, diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 09b613350705..4c9bc5ca3a9b 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -85,6 +85,8 @@ function WorkspaceMembersPage(props) { const isOfflineAndNoMemberDataAvailable = _.isEmpty(props.policyMembers) && props.network.isOffline; const prevPersonalDetails = usePrevious(props.personalDetails); + console.log('POLICY', props.policy); + /** * Get filtered personalDetails list with current policyMembers * @param {Object} policyMembers diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e6e3240d1b23..8c24e5d98dca 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2,6 +2,39 @@ import {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import * as OnyxCommon from './OnyxCommon'; +type Unit = 'mi' | 'km'; + +type Rate = { + name: string; + rate: number; + currency?: string; + customUnitRateID?: string; + errors?: OnyxCommon.Errors; + pendingAction?: string; +}; + +type NewCustomUnit = { + name: string; + customUnitID?: string; + attributes: { + unit: Unit; + }; + rates: Rate; + pendingAction?: string; + errors?: OnyxCommon.Errors; +}; + +type CustomUnit = { + name: string; + customUnitID?: string; + attributes: { + unit: Unit; + }; + rates: Record; + pendingAction?: string; + errors?: OnyxCommon.Errors; +}; + type Policy = { /** The ID of the policy */ id: string; @@ -19,7 +52,7 @@ type Policy = { owner: string; /** The accountID of the policy owner */ - ownerAccountID: number; + ownerAccountID?: number; /** The output currency for the policy */ outputCurrency: string; @@ -34,7 +67,7 @@ type Policy = { pendingAction?: OnyxCommon.PendingAction; /** A list of errors keyed by microtime */ - errors: OnyxCommon.Errors; + errors?: OnyxCommon.Errors; /** Whether this policy was loaded from a policy summary, or loaded completely with all of its values */ isFromFullPolicy?: boolean; @@ -43,22 +76,33 @@ type Policy = { lastModified?: string; /** The custom units data for this policy */ - customUnits?: Record; + customUnits?: Record; /** Whether chat rooms can be created and used on this policy. Enabled manually by CQ/JS snippet. Always true for free policies. */ - areChatRoomsEnabled: boolean; + areChatRoomsEnabled?: boolean; /** Whether policy expense chats can be created and used on this policy. Enabled manually by CQ/JS snippet. Always true for free policies. */ isPolicyExpenseChatEnabled: boolean; /** Whether the scheduled submit is enabled */ - autoReporting: boolean; + autoReporting?: boolean; /** The scheduled submit frequency set up on the this policy */ - autoReportingFrequency: ValueOf; + autoReportingFrequency?: ValueOf; /** The employee list of the policy */ employeeList?: []; + + makeMeAdmin?: boolean; + + pendingFields?: Record; + + originalFileName?: string; + + alertMessage?: string; + + primaryLoginsInvited?: Record; }; export default Policy; +export type {CustomUnit, NewCustomUnit}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 81a92c4bf603..0dea4c635a28 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -108,8 +108,9 @@ type Report = { lastMessageHtml?: string; welcomeMessage?: string; lastActorAccountID?: number; - ownerAccountID?: number; - participantAccountIDs?: number[]; + ownerAccountID?: string; + ownerEmail?: string; + participantAccountIDs?: string[]; total?: number; currency?: string; parentReportActionIDs?: number[]; @@ -130,6 +131,8 @@ type Report = { /** Pending fields for the report */ pendingFields?: Record; + pendingAction?: string; + /** The ID of the preexisting report (it is possible that we optimistically created a Report for which a report already exists) */ preexistingReportID?: string; From a5ea5d1d9a65def57aa44da5c9481bfbe758676d Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Wed, 6 Dec 2023 13:42:39 +0100 Subject: [PATCH 003/205] TS migration of Policy.js --- src/libs/actions/Policy.ts | 12 ++++-------- src/types/onyx/Report.ts | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 200eb00f4822..c235cae98bee 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -61,10 +61,7 @@ let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, waitForCollectionCallback: true, - // callback: (val) => (allPolicyMembers = val), callback: (val) => { - // console.log('POLICYMEMBERS', val); - // console.log('TYPEPOLICYMEMBERS', typeof Object.keys(val)[0]); allPolicyMembers = val; }, }); @@ -119,7 +116,6 @@ function updateLastAccessedWorkspace(policyID: OnyxEntry) { /** * Check if the user has any active free policies (aka workspaces) - */ function hasActiveFreePolicy(policies: Array> | Record>): boolean { const adminFreePolicies = Object.values(policies).filter((policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); @@ -292,7 +288,7 @@ type OptimisticAnnounceRoomMembers = { /** * Build optimistic data for removing users from the announcement room */ -function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: string[]) { +function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: number[]) { const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); const announceRoomMembers: OptimisticAnnounceRoomMembers = { onyxOptimisticData: [], @@ -324,7 +320,7 @@ function removeOptimisticAnnounceRoomMembers(policyID: string, accountIDs: strin /** * Remove the passed members from the policy employeeList */ -function removeMembers(accountIDs: string[], policyID: string) { +function removeMembers(accountIDs: number[], policyID: string) { // In case user selects only themselves (admin), their email will be filtered out and the members // array passed will be empty, prevent the function from proceeding in that case as there is no one to remove if (accountIDs.length === 0) { @@ -377,7 +373,7 @@ function removeMembers(accountIDs: string[], policyID: string) { // Take the current policy members and remove them optimistically // console.log('POLICYMEMBERS', allPolicyMembers); const policyMemberAccountIDs = Object.keys(allPolicyMembers?.[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`] ?? {}); - const remainingMemberAccountIDs = policyMemberAccountIDs.filter((e) => !accountIDs.includes(e)); + const remainingMemberAccountIDs = policyMemberAccountIDs.filter((e) => !accountIDs.includes(Number(e))); const remainingLogins: string[] = PersonalDetailsUtils.getLoginsByAccountIDs(remainingMemberAccountIDs); const invitedPrimaryToSecondaryLogins: Record = {}; @@ -1804,7 +1800,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { }); // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved - const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID ?? '', policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 28f772bd1ef0..d49bf2169022 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -108,9 +108,9 @@ type Report = { lastMessageHtml?: string; welcomeMessage?: string; lastActorAccountID?: number; - ownerAccountID?: string; + ownerAccountID?: number; ownerEmail?: string; - participantAccountIDs?: string[]; + participantAccountIDs?: number[]; total?: number; currency?: string; parentReportActionIDs?: number[]; From 32f95b62675a9e868a6ab01ea653657d3a53a5e9 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Mon, 11 Dec 2023 16:17:06 +0100 Subject: [PATCH 004/205] [TS migration] Migrate 'VideoChatButtonAndMenu' component to TypeScript --- src/components/Popover/types.ts | 15 +++---- src/components/PopoverProvider/index.tsx | 39 +++++++++------- src/components/PopoverProvider/types.ts | 13 +++--- ...Menu.js => BaseVideoChatButtonAndMenu.tsx} | 44 ++++++++----------- .../{index.android.js => index.android.tsx} | 7 ++- .../{index.js => index.tsx} | 7 ++- .../VideoChatButtonAndMenu/types.ts | 9 ++++ .../videoChatButtonAndMenuPropTypes.js | 16 ------- 8 files changed, 71 insertions(+), 79 deletions(-) rename src/components/VideoChatButtonAndMenu/{BaseVideoChatButtonAndMenu.js => BaseVideoChatButtonAndMenu.tsx} (75%) rename src/components/VideoChatButtonAndMenu/{index.android.js => index.android.tsx} (76%) rename src/components/VideoChatButtonAndMenu/{index.js => index.tsx} (68%) create mode 100644 src/components/VideoChatButtonAndMenu/types.ts delete mode 100644 src/components/VideoChatButtonAndMenu/videoChatButtonAndMenuPropTypes.js diff --git a/src/components/Popover/types.ts b/src/components/Popover/types.ts index 7f7e2829770c..103ab0404081 100644 --- a/src/components/Popover/types.ts +++ b/src/components/Popover/types.ts @@ -1,8 +1,8 @@ +import {ReactNode, RefObject} from 'react'; +import {View} from 'react-native'; import BaseModalProps, {PopoverAnchorPosition} from '@components/Modal/types'; import {WindowDimensionsProps} from '@components/withWindowDimensions/types'; -type AnchorAlignment = {horizontal: string; vertical: string}; - type PopoverDimensions = { width: number; height: number; @@ -12,14 +12,11 @@ type PopoverProps = BaseModalProps & { /** The anchor position of the popover */ anchorPosition?: PopoverAnchorPosition; - /** The anchor alignment of the popover */ - anchorAlignment: AnchorAlignment; - /** The anchor ref of the popover */ - anchorRef: React.RefObject; + anchorRef: RefObject; /** Whether disable the animations */ - disableAnimation: boolean; + disableAnimation?: boolean; /** Whether we don't want to show overlay */ withoutOverlay: boolean; @@ -28,13 +25,13 @@ type PopoverProps = BaseModalProps & { popoverDimensions?: PopoverDimensions; /** The ref of the popover */ - withoutOverlayRef?: React.RefObject; + withoutOverlayRef?: RefObject; /** Whether we want to show the popover on the right side of the screen */ fromSidebarMediumScreen?: boolean; /** The popover children */ - children: React.ReactNode; + children: ReactNode; }; type PopoverWithWindowDimensionsProps = PopoverProps & WindowDimensionsProps; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 06345ebdbc1c..1be7c7a95bc1 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,18 +1,26 @@ -import React from 'react'; +import React, {createContext, RefObject, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; import {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; -const PopoverContext = React.createContext({ +const PopoverContext = createContext({ onOpen: () => {}, popover: {}, close: () => {}, isOpen: false, }); +function elementContains(ref: RefObject | undefined, target: EventTarget | null) { + if (ref?.current && 'contains' in ref?.current && ref?.current?.contains(target as Node)) { + return true; + } + return false; +} + function PopoverContextProvider(props: PopoverContextProps) { - const [isOpen, setIsOpen] = React.useState(false); - const activePopoverRef = React.useRef(null); + const [isOpen, setIsOpen] = useState(false); + const activePopoverRef = useRef(null); - const closePopover = React.useCallback((anchorRef?: React.RefObject) => { + const closePopover = useCallback((anchorRef?: RefObject) => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return; } @@ -25,10 +33,9 @@ function PopoverContextProvider(props: PopoverContextProps) { setIsOpen(false); }, []); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node) || activePopoverRef.current?.anchorRef?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target) || elementContains(activePopoverRef.current?.anchorRef, e.target)) { return; } const ref = activePopoverRef.current?.anchorRef; @@ -40,9 +47,9 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target)) { return; } closePopover(); @@ -53,7 +60,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; @@ -66,7 +73,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = () => { if (document.hasFocus()) { return; @@ -79,9 +86,9 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - React.useEffect(() => { + useEffect(() => { const listener = (e: Event) => { - if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { + if (elementContains(activePopoverRef.current?.ref, e.target)) { return; } @@ -93,7 +100,7 @@ function PopoverContextProvider(props: PopoverContextProps) { }; }, [closePopover]); - const onOpen = React.useCallback( + const onOpen = useCallback( (popoverParams: AnchorRef) => { if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { closePopover(activePopoverRef.current.anchorRef); @@ -107,7 +114,7 @@ function PopoverContextProvider(props: PopoverContextProps) { [closePopover], ); - const contextValue = React.useMemo( + const contextValue = useMemo( () => ({ onOpen, close: closePopover, diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index ffd0087cd5ff..dc0208e10dd7 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -1,18 +1,21 @@ +import {ReactNode, RefObject} from 'react'; +import {View} from 'react-native'; + type PopoverContextProps = { - children: React.ReactNode; + children: ReactNode; }; type PopoverContextValue = { onOpen?: (popoverParams: AnchorRef) => void; popover?: AnchorRef | Record | null; - close: (anchorRef?: React.RefObject) => void; + close: (anchorRef?: RefObject) => void; isOpen: boolean; }; type AnchorRef = { - ref: React.RefObject; - close: (anchorRef?: React.RefObject) => void; - anchorRef: React.RefObject; + ref: RefObject; + close: (anchorRef?: RefObject) => void; + anchorRef: RefObject; onOpenCallback?: () => void; onCloseCallback?: () => void; }; diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx similarity index 75% rename from src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js rename to src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx index 4d5affafc407..14dbcb9a118b 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Dimensions, View} from 'react-native'; -import _ from 'underscore'; import GoogleMeetIcon from '@assets/images/google-meet.svg'; import ZoomIcon from '@assets/images/zoom-icon.svg'; import Icon from '@components/Icon'; @@ -10,37 +8,34 @@ import MenuItem from '@components/MenuItem'; import Popover from '@components/Popover'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import compose from '@libs/compose'; +import useLocalize from '@hooks/useLocalize'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import * as Link from '@userActions/Link'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; -import {defaultProps, propTypes as videoChatButtonAndMenuPropTypes} from './videoChatButtonAndMenuPropTypes'; +import VideoChatButtonAndMenuProps from './types'; -const propTypes = { +type BaseVideoChatButtonAndMenuProps = VideoChatButtonAndMenuProps & { /** Link to open when user wants to create a new google meet meeting */ - googleMeetURL: PropTypes.string.isRequired, - - ...videoChatButtonAndMenuPropTypes, - ...withLocalizePropTypes, - ...windowDimensionsPropTypes, + googleMeetURL: string; }; -function BaseVideoChatButtonAndMenu(props) { +function BaseVideoChatButtonAndMenu(props: BaseVideoChatButtonAndMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); const [isVideoChatMenuActive, setIsVideoChatMenuActive] = useState(false); const [videoChatIconPosition, setVideoChatIconPosition] = useState({x: 0, y: 0}); - const videoChatIconWrapperRef = useRef(null); - const videoChatButtonRef = useRef(null); + const videoChatIconWrapperRef = useRef(null); + const videoChatButtonRef = useRef(null); const menuItemData = [ { icon: ZoomIcon, - text: props.translate('videoChatButtonAndMenu.zoom'), + text: translate('videoChatButtonAndMenu.zoom'), onPress: () => { setIsVideoChatMenuActive(false); Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); @@ -48,7 +43,7 @@ function BaseVideoChatButtonAndMenu(props) { }, { icon: GoogleMeetIcon, - text: props.translate('videoChatButtonAndMenu.googleMeet'), + text: translate('videoChatButtonAndMenu.googleMeet'), onPress: () => { setIsVideoChatMenuActive(false); Link.openExternalLink(props.googleMeetURL); @@ -87,12 +82,12 @@ function BaseVideoChatButtonAndMenu(props) { ref={videoChatIconWrapperRef} onLayout={measureVideoChatIconPosition} > - + { // Drop focus to avoid blue focus ring. - videoChatButtonRef.current.blur(); + videoChatButtonRef.current?.blur(); // If this is the Concierge chat, we'll open the modal for requesting a setup call instead if (props.isConcierge && props.guideCalendarLink) { @@ -102,7 +97,7 @@ function BaseVideoChatButtonAndMenu(props) { setIsVideoChatMenuActive((previousVal) => !previousVal); })} style={styles.touchableButtonImage} - accessibilityLabel={props.translate('videoChatButtonAndMenu.tooltip')} + accessibilityLabel={translate('videoChatButtonAndMenu.tooltip')} role={CONST.ACCESSIBILITY_ROLE.BUTTON} > - - {_.map(menuItemData, ({icon, text, onPress}) => ( + + {menuItemData.map(({icon, text, onPress}) => ( Date: Mon, 11 Dec 2023 18:19:39 +0100 Subject: [PATCH 005/205] [TS migration] Migrate 'ShowContextMenuContext.js' component to TypeScript --- ...MenuContext.js => ShowContextMenuContext.tsx} | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) rename src/components/{ShowContextMenuContext.js => ShowContextMenuContext.tsx} (67%) diff --git a/src/components/ShowContextMenuContext.js b/src/components/ShowContextMenuContext.tsx similarity index 67% rename from src/components/ShowContextMenuContext.js rename to src/components/ShowContextMenuContext.tsx index 6248478e5fea..b2c1835d59e7 100644 --- a/src/components/ShowContextMenuContext.js +++ b/src/components/ShowContextMenuContext.tsx @@ -3,6 +3,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as ReportUtils from '@libs/ReportUtils'; import * as ContextMenuActions from '@pages/home/report/ContextMenu/ContextMenuActions'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import ReportAction from '@src/types/onyx/ReportAction'; const ShowContextMenuContext = React.createContext({ anchor: null, @@ -16,17 +17,18 @@ ShowContextMenuContext.displayName = 'ShowContextMenuContext'; /** * Show the report action context menu. * - * @param {Object} event - Press event object - * @param {Element} anchor - Context menu anchor - * @param {String} reportID - Active Report ID - * @param {Object} action - ReportAction for ContextMenu - * @param {Function} checkIfContextMenuActive Callback to update context menu active state - * @param {Boolean} [isArchivedRoom=false] - Is the report an archived room + * @param event - Press event object + * @param anchor - Context menu anchor + * @param reportID - Active Report ID + * @param action - ReportAction for ContextMenu + * @param checkIfContextMenuActive Callback to update context menu active state + * @param isArchivedRoom - Is the report an archived room */ -function showContextMenuForReport(event, anchor, reportID, action, checkIfContextMenuActive, isArchivedRoom = false) { +function showContextMenuForReport(event: Event, anchor: HTMLElement, reportID: string, action: ReportAction, checkIfContextMenuActive: () => void, isArchivedRoom = false) { if (!DeviceCapabilities.canUseTouchScreen()) { return; } + ReportActionContextMenu.showContextMenu( ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION, event, From ecb7dfe2bf90b07f454f9990cfe8250b36cb41c4 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Wed, 13 Dec 2023 13:36:07 +0100 Subject: [PATCH 006/205] Add changes to ReportUtils & merge main --- assets/emojis/common.ts | 2 +- src/libs/ReportUtils.ts | 124 +++++++++++++----------------- src/libs/actions/Policy.ts | 66 ++++++++-------- src/types/onyx/OriginalMessage.ts | 11 ++- src/types/onyx/PersonalDetails.ts | 4 +- src/types/onyx/Report.ts | 22 ++++-- src/types/onyx/ReportAction.ts | 52 ++++++++++++- src/types/onyx/index.ts | 3 +- 8 files changed, 166 insertions(+), 118 deletions(-) diff --git a/assets/emojis/common.ts b/assets/emojis/common.ts index cbefb21cf2d6..8d2fded0a4a1 100644 --- a/assets/emojis/common.ts +++ b/assets/emojis/common.ts @@ -91,7 +91,7 @@ const emojis: PickerEmojis = [ code: '😊', }, { - name: 'innocent', + name: ':innocent:', code: '😇', }, { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3e55a97cd294..c53b50122faf 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6,7 +6,6 @@ import lodashEscape from 'lodash/escape'; import lodashFindLastIndex from 'lodash/findLastIndex'; import lodashIntersection from 'lodash/intersection'; import lodashIsEqual from 'lodash/isEqual'; -import React from 'react'; import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import {SvgProps} from 'react-native-svg'; import {ValueOf} from 'type-fest'; @@ -16,10 +15,11 @@ import CONST from '@src/CONST'; import {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {Beta, Login, PersonalDetails, Policy, PolicyTags, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; +import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyTags, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import {ChangeLog, IOUMessage, OriginalMessageActionName} from '@src/types/onyx/OriginalMessage'; -import {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import {ChangeLog, IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage'; +import {NotificationPreference} from '@src/types/onyx/Report'; +import {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import {Receipt, WaypointCollection} from '@src/types/onyx/Transaction'; import DeepValueOf from '@src/types/utils/DeepValueOf'; import {EmptyObject, isEmptyObject, isNotEmptyObject} from '@src/types/utils/EmptyObject'; @@ -187,10 +187,8 @@ type OptimisticClosedReportAction = Pick< 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'originalMessage' | 'pendingAction' | 'person' | 'reportActionID' | 'shouldShow' >; -type OptimisticCreatedReportAction = Pick< - ReportAction, - 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'created' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'pendingAction' ->; +type OptimisticCreatedReportAction = OriginalMessageCreated & + Pick; type OptimisticChatReport = Pick< Report, @@ -277,20 +275,20 @@ type OptimisticTaskReport = Pick< type TransactionDetails = | { - created: string; - amount: number; - currency: string; - merchant: string; - waypoints?: WaypointCollection; - comment: string; - category: string; - billable: boolean; - tag: string; - mccGroup?: ValueOf; - cardID: number; - originalAmount: number; - originalCurrency: string; - } + created: string; + amount: number; + currency: string; + merchant: string; + waypoints?: WaypointCollection; + comment: string; + category: string; + billable: boolean; + tag: string; + mccGroup?: ValueOf; + cardID: number; + originalAmount: number; + originalCurrency: string; +} | undefined; type OptimisticIOUReport = Pick< @@ -1409,7 +1407,7 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f } function getDisplayNamesWithTooltips( - personalDetailsList: PersonalDetails[] | Record, + personalDetailsList: PersonalDetails[] | PersonalDetailsList, isMultipleParticipantReport: boolean, shouldFallbackToHidden = true, ): DisplayNameWithTooltips { @@ -1423,7 +1421,7 @@ function getDisplayNamesWithTooltips( const avatar = UserUtils.getDefaultAvatar(accountID); let pronouns = user.pronouns; - if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { + if (pronouns?.startsWith(CONST.PRONOUNS.PREFIX)) { const pronounTranslationKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); pronouns = Localize.translateLocal(`pronouns.${pronounTranslationKey}` as TranslationPaths); } @@ -1547,9 +1545,9 @@ function isUnreadWithMention(report: OnyxEntry | OptionData): boolean { /** * Determines if the option requires action from the current user. This can happen when it: - - is unread and the user was mentioned in one of the unread comments - - is for an outstanding task waiting on the user - - has an outstanding child money request that is waiting for an action from the current user (e.g. pay, approve, add bank account) + - is unread and the user was mentioned in one of the unread comments + - is for an outstanding task waiting on the user + - has an outstanding child money request that is waiting for an action from the current user (e.g. pay, approve, add bank account) * * @param option (report or optionItem) * @param parentReportAction (the report action the current report is a thread of) @@ -1828,12 +1826,12 @@ function canEditReportAction(reportAction: OnyxEntry): boolean { return Boolean( reportAction?.actorAccountID === currentUserAccountID && - isCommentOrIOU && - canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions - !isReportMessageAttachment(reportAction?.message?.[0] ?? {type: '', text: ''}) && - !ReportActionsUtils.isDeletedAction(reportAction) && - !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && - reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + isCommentOrIOU && + canEditMoneyRequest(reportAction) && // Returns true for non-IOU actions + !isReportMessageAttachment(reportAction?.message?.[0] ?? {type: '', text: ''}) && + !ReportActionsUtils.isDeletedAction(reportAction) && + !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && + reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); } @@ -2376,7 +2374,7 @@ function getParsedComment(text: string): string { return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : lodashEscape(text); } -function buildOptimisticAddCommentReportAction(text?: string, file?: File & {source: string; uri: string}): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: File): OptimisticReportAction { const parser = new ExpensiMark(); const commentText = getParsedComment(text ?? ''); const isAttachment = !text && file !== undefined; @@ -2994,9 +2992,9 @@ function updateReportPreview( childMoneyRequestCount: (reportPreviewAction?.childMoneyRequestCount ?? 0) + (isPayRequest ? 0 : 1), childRecentReceiptTransactionIDs: hasReceipt ? { - ...(transaction && {[transaction.transactionID]: transaction?.created}), - ...previousTransactions, - } + ...(transaction && {[transaction.transactionID]: transaction?.created}), + ...previousTransactions, + } : recentReceiptTransactions, // As soon as we add a transaction without a receipt to the report, it will have ready money requests, // so we remove the whisper @@ -3052,7 +3050,7 @@ function buildOptimisticChatReport( oldPolicyName = '', visibility: ValueOf | undefined = undefined, writeCapability: ValueOf | undefined = undefined, - notificationPreference: string | number = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, parentReportActionID = '', parentReportID = '', welcomeMessage = '', @@ -3578,12 +3576,12 @@ function canFlagReportAction(reportAction: OnyxEntry, reportID: st return Boolean( !isCurrentUserAction && - reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && - !ReportActionsUtils.isDeletedAction(reportAction) && - !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && - isNotEmptyObject(report) && - report && - isAllowedToComment(report), + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && + !ReportActionsUtils.isDeletedAction(reportAction) && + !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && + isNotEmptyObject(report) && + report && + isAllowedToComment(report), ); } @@ -4227,12 +4225,12 @@ function getChannelLogMemberMessage(reportAction: OnyxEntry): stri function isGroupChat(report: OnyxEntry): boolean { return Boolean( report && - !isChatThread(report) && - !isTaskReport(report) && - !isMoneyRequestReport(report) && - !isArchivedRoom(report) && - !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && - (report.participantAccountIDs?.length ?? 0) > 2, + !isChatThread(report) && + !isTaskReport(report) && + !isMoneyRequestReport(report) && + !isArchivedRoom(report) && + !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && + (report.participantAccountIDs?.length ?? 0) > 2, ); } @@ -4252,33 +4250,16 @@ function shouldDisableWelcomeMessage(report: OnyxEntry, policy: OnyxEntr return isMoneyRequestReport(report) || isArchivedRoom(report) || !isChatRoom(report) || isChatThread(report) || !PolicyUtils.isPolicyAdmin(policy); } -function shouldAutoFocusOnKeyPress(event: KeyboardEvent): boolean { - if (event.key.length > 1) { - return false; - } - - // If a key is pressed in combination with Meta, Control or Alt do not focus - if (event.ctrlKey || event.metaKey) { - return false; - } - - if (event.code === 'Space') { - return false; - } - - return true; -} - /** * Navigates to the appropriate screen based on the presence of a private note for the current user. */ function navigateToPrivateNotes(report: Report, session: Session) { - if (isEmpty(report) || isEmpty(session)) { + if (isEmpty(report) || isEmpty(session) || !session.accountID) { return; } - const currentUserPrivateNote = report.privateNotes?.[String(session.accountID)]?.note ?? ''; + const currentUserPrivateNote = report.privateNotes?.[session.accountID]?.note ?? ''; if (isEmpty(currentUserPrivateNote)) { - Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, String(session.accountID))); + Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, session.accountID)); return; } Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); @@ -4451,7 +4432,6 @@ export { shouldDisableWelcomeMessage, navigateToPrivateNotes, canEditWriteCapability, - shouldAutoFocusOnKeyPress, }; -export type {OptionData, OptimisticClosedReportAction}; +export type {OptionData, OptimisticChatReport, OptimisticClosedReportAction}; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 14ca1b169e78..5cf0c57b3882 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -14,7 +14,6 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import {OptimisticClosedReportAction} from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -181,7 +180,7 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string return; } - const filteredPolicies = Object.values(allPolicies).filter((policy) => policy?.id !== policyID); + const filteredPolicies = Object.values(allPolicies).filter((policy): policy is Policy => policy?.id !== policyID); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -191,22 +190,22 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string errors: null, }, }, - ...reports.map(({reportID}) => ({ + ...reports.map(({reportID}) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { + value: ({ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS.CLOSED, hasDraft: false, - oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name, - }, + oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? '', + }), })), - ...reports.map(({reportID}) => ({ + ...(reports.map(({reportID}) => ({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`, value: null, - })), + }))), // Add closed actions to all chat reports linked to this policy ...reports.map(({reportID, ownerAccountID}) => { @@ -215,8 +214,8 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string if (!!ownerAccountID && ownerAccountID !== CONST.POLICY.OWNER_ACCOUNT_ID_FAKE) { emailClosingReport = allPersonalDetails?.[ownerAccountID]?.login ?? ''; } - const optimisticClosedReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); - const optimisticReportActions: Record = {}; + const optimisticClosedReportAction: ReportAction = ReportUtils.buildOptimisticClosedReportAction(emailClosingReport, policyName, CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED); + const optimisticReportActions: Record = {}; optimisticReportActions[optimisticClosedReportAction.reportActionID] = optimisticClosedReportAction; return { onyxMethod: Onyx.METHOD.MERGE, @@ -226,7 +225,7 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string }), ...(!hasActiveFreePolicy(filteredPolicies) - ? [ + ? ([ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, @@ -234,8 +233,8 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string errors: null, }, }, - ] - : []), + ]) + : ([])), ]; // Restore the old report stateNum and statusNum @@ -433,7 +432,7 @@ function removeMembers(accountIDs: number[], policyID: string) { }, ]; - const filteredWorkspaceChats = workspaceChats.filter((e) => e !== null) as Report[]; + const filteredWorkspaceChats = workspaceChats.filter((e): e is Report => e !== null); const failureData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -538,7 +537,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: R workspaceMembersChats.onyxOptimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReport.reportID}`, - value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction as ReportAction}, + value: {[optimisticCreatedAction.reportActionID]: optimisticCreatedAction}, }); workspaceMembersChats.onyxSuccessData.push({ @@ -795,7 +794,7 @@ function updateGeneralSettings(policyID: string, name: string, currency: string) onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { - ...((allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}) as Policy), + ...((allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {} as Policy)), pendingFields: { generalSettings: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -821,7 +820,7 @@ function updateGeneralSettings(policyID: string, name: string, currency: string) }, }, } - : {}) as Record), + : {})), }, }, ]; @@ -971,7 +970,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [currentCustomUnit.rates.customUnitRateID]: { + [newCustomUnit.rates.customUnitRateID]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -1592,7 +1591,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, - value: announceReportActionData as Record, + value: announceReportActionData, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1607,7 +1606,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, - value: adminsReportActionData as Record, + value: adminsReportActionData, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1622,7 +1621,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, - value: workspaceChatReportActionData as Record, + value: workspaceChatReportActionData, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1833,18 +1832,21 @@ function createWorkspaceFromIOUPayment(iouReport: Report): string | undefined { }, }); - // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, - value: { - [reportPreview.reportActionID]: { - ...reportPreview, - message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace), - created: DateUtils.getDBTime(), + if (reportPreview?.reportActionID) { + // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: { + [reportPreview.reportActionID]: ({ + ...reportPreview, + message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace), + created: DateUtils.getDBTime(), + }), }, - }, - }); + }); + } + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index f76fbd5ffd7d..fc7f8eb8ba31 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -45,6 +45,7 @@ type IOUMessage = { /** Only exists when we are sending money */ IOUDetails?: IOUDetails; }; + type OriginalMessageIOU = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.IOU; originalMessage: IOUMessage; @@ -69,7 +70,7 @@ type DecisionName = ValueOf< >; type Decision = { decision: DecisionName; - timestamp: string; + timestamp?: string; }; type User = { @@ -105,6 +106,7 @@ type OriginalMessageAddComment = { reactions?: Reaction[]; }; }; + type OriginalMessageSubmitted = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.SUBMITTED; originalMessage: unknown; @@ -117,7 +119,7 @@ type OriginalMessageClosed = { type OriginalMessageCreated = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.CREATED; - originalMessage: unknown; + originalMessage?: unknown; }; type OriginalMessageRenamed = { @@ -183,7 +185,8 @@ type OriginalMessagePolicyTask = { | typeof CONST.REPORT.ACTIONS.TYPE.TASKEDITED | typeof CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED | typeof CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED - | typeof CONST.REPORT.ACTIONS.TYPE.TASKREOPENED; + | typeof CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + | typeof CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE; originalMessage: unknown; }; @@ -231,4 +234,4 @@ type OriginalMessage = | OriginalMessageMoved; export default OriginalMessage; -export type {ChronosOOOEvent, Decision, Reaction, ActionName, IOUMessage, Closed, OriginalMessageActionName, ChangeLog}; +export type {ChronosOOOEvent, Decision, Reaction, ActionName, IOUMessage, Closed, OriginalMessageActionName, ChangeLog, OriginalMessageIOU, OriginalMessageCreated}; diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts index af559eafd0a1..8f824272230e 100644 --- a/src/types/onyx/PersonalDetails.ts +++ b/src/types/onyx/PersonalDetails.ts @@ -76,6 +76,8 @@ type PersonalDetails = { payPalMeAddress?: string; }; +type PersonalDetailsList = Record; + export default PersonalDetails; -export type {Timezone, SelectedTimezone}; +export type {Timezone, SelectedTimezone, PersonalDetailsList}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 41a2a4d4effd..95086aeda0a1 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -3,6 +3,16 @@ import CONST from '@src/CONST'; import * as OnyxCommon from './OnyxCommon'; import PersonalDetails from './PersonalDetails'; +type NotificationPreference = ValueOf; + +type WriteCapability = ValueOf; + +type Note = { + note: string; + errors?: OnyxCommon.Errors; + pendingAction?: OnyxCommon.PendingAction; +}; + type Report = { /** The specific type of chat */ chatType?: ValueOf; @@ -47,7 +57,7 @@ type Report = { lastMentionedTime?: string | null; /** The current user's notification preference for this report */ - notificationPreference?: string | number; + notificationPreference?: NotificationPreference; /** The policy name to use */ policyName?: string | null; @@ -89,7 +99,7 @@ type Report = { statusNum?: ValueOf; /** Which user role is capable of posting messages on the report */ - writeCapability?: ValueOf; + writeCapability?: WriteCapability; /** The report type */ type?: string; @@ -136,8 +146,7 @@ type Report = { /** Pending fields for the report */ pendingFields?: Record; - - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; /** The ID of the preexisting report (it is possible that we optimistically created a Report for which a report already exists) */ preexistingReportID?: string; @@ -148,7 +157,10 @@ type Report = { isChatRoom?: boolean; participantsList?: Array>; text?: string; - privateNotes?: Record; + privateNotes?: Record; + isLoadingPrivateNotes?: boolean; }; export default Report; + +export type {NotificationPreference, WriteCapability}; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 64e1eb0b7c88..b193d5e720ec 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -1,8 +1,10 @@ import {ValueOf} from 'type-fest'; import {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; +import {EmptyObject} from '@src/types/utils/EmptyObject'; import * as OnyxCommon from './OnyxCommon'; import OriginalMessage, {Decision, Reaction} from './OriginalMessage'; +import {NotificationPreference} from './Report'; import {Receipt} from './Transaction'; type Message = { @@ -52,6 +54,37 @@ type Message = { taskReportID?: string; }; +type ImageMetadata = { + /** The height of the image. */ + height?: number; + + /** The width of the image. */ + width?: number; + + /** The URL of the image. */ + url?: string; +}; + +type LinkMetadata = { + /** The URL of the link. */ + url?: string; + + /** A description of the link. */ + description?: string; + + /** The title of the link. */ + title?: string; + + /** The publisher of the link. */ + publisher?: string; + + /** The image associated with the link. */ + image?: ImageMetadata; + + /** The provider logo associated with the link. */ + logo?: ImageMetadata; +}; + type Person = { type?: string; style?: string; @@ -79,6 +112,9 @@ type ReportActionBase = { /** report action message */ message?: Message[]; + /** report action message */ + previousMessage?: Message[]; + /** Whether we have received a response back from the server */ isLoading?: boolean; @@ -121,7 +157,7 @@ type ReportActionBase = { isFirstItem?: boolean; /** Informations about attachments of report action */ - attachmentInfo?: (File & {source: string; uri: string}) | Record; + attachmentInfo?: File | EmptyObject; /** Receipt tied to report action */ receipt?: Receipt; @@ -129,15 +165,27 @@ type ReportActionBase = { /** ISO-formatted datetime */ lastModified?: string; + /** Is this action pending? */ pendingAction?: OnyxCommon.PendingAction; delegateAccountID?: string; /** Server side errors keyed by microtime */ errors?: OnyxCommon.Errors; + /** Whether the report action is attachment */ isAttachment?: boolean; + + /** Recent receipt transaction IDs keyed by reportID */ childRecentReceiptTransactionIDs?: Record; + + /** ReportID of the report action */ reportID?: string; + + /** Metadata of the link */ + linkMetadata?: LinkMetadata[]; + + /** The current user's notification preference for this report's child */ + childReportNotificationPreference?: NotificationPreference; }; type ReportAction = ReportActionBase & OriginalMessage; @@ -145,4 +193,4 @@ type ReportAction = ReportActionBase & OriginalMessage; type ReportActions = Record; export default ReportAction; -export type {Message, ReportActions}; +export type {ReportActions, ReportActionBase, Message}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 8329b56dc4b8..42bd4500d8ad 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -19,7 +19,7 @@ import Modal from './Modal'; import Network from './Network'; import {OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer'; import PersonalBankAccount from './PersonalBankAccount'; -import PersonalDetails from './PersonalDetails'; +import PersonalDetails, {PersonalDetailsList} from './PersonalDetails'; import PlaidData from './PlaidData'; import Policy from './Policy'; import PolicyCategory, {PolicyCategories} from './PolicyCategory'; @@ -80,6 +80,7 @@ export type { OnyxUpdatesFromServer, PersonalBankAccount, PersonalDetails, + PersonalDetailsList, PlaidData, Policy, PolicyCategory, From 908038260d76cae83f3734a2e4a6fb0c61153d50 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 21 Dec 2023 14:51:25 +0100 Subject: [PATCH 007/205] migrate everything that is possible at the moment --- src/components/OfflineWithFeedback.tsx | 2 +- src/components/SectionList/index.tsx | 23 +- src/components/SectionList/types.ts | 4 +- src/components/SelectionList/BaseListItem.tsx | 137 +++++ .../SelectionList/BaseSelectionList.tsx | 525 ++++++++++++++++++ .../SelectionList/RadioListItem.tsx | 45 ++ src/components/SelectionList/UserListItem.tsx | 53 ++ src/components/SelectionList/types.ts | 221 ++++++++ src/components/SubscriptAvatar.tsx | 1 + src/hooks/useKeyboardShortcut.ts | 2 +- 10 files changed, 997 insertions(+), 16 deletions(-) create mode 100644 src/components/SelectionList/BaseListItem.tsx create mode 100644 src/components/SelectionList/BaseSelectionList.tsx create mode 100644 src/components/SelectionList/RadioListItem.tsx create mode 100644 src/components/SelectionList/UserListItem.tsx create mode 100644 src/components/SelectionList/types.ts diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 5fcf1fe7442b..4522595826af 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -18,7 +18,7 @@ import MessagesRow from './MessagesRow'; type OfflineWithFeedbackProps = ChildrenProps & { /** The type of action that's pending */ - pendingAction: OnyxCommon.PendingAction; + pendingAction?: OnyxCommon.PendingAction; /** Determine whether to hide the component's children if deletion is pending */ shouldHideOnDelete?: boolean; diff --git a/src/components/SectionList/index.tsx b/src/components/SectionList/index.tsx index 1c89b50468dd..ef178a3bb1e3 100644 --- a/src/components/SectionList/index.tsx +++ b/src/components/SectionList/index.tsx @@ -1,16 +1,15 @@ -import React, {forwardRef} from 'react'; -import {SectionList as RNSectionList} from 'react-native'; -import ForwardedSectionList from './types'; +import React, {ForwardedRef, forwardRef} from 'react'; +import {SectionList as RNSectionList, SectionListProps} from 'react-native'; // eslint-disable-next-line react/function-component-definition -const SectionList: ForwardedSectionList = (props, ref) => ( - -); - -SectionList.displayName = 'SectionList'; +function SectionList(props: SectionListProps, ref: ForwardedRef>) { + return ( + + ); +} export default forwardRef(SectionList); diff --git a/src/components/SectionList/types.ts b/src/components/SectionList/types.ts index 093cb8f4e77c..84f38171a4f5 100644 --- a/src/components/SectionList/types.ts +++ b/src/components/SectionList/types.ts @@ -1,8 +1,8 @@ import {ForwardedRef} from 'react'; import {SectionList, SectionListProps} from 'react-native'; -type ForwardedSectionList = { - (props: SectionListProps, ref: ForwardedRef): React.ReactNode; +type ForwardedSectionList = { + (props: SectionListProps, ref: ForwardedRef>): React.ReactNode; displayName: string; }; diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx new file mode 100644 index 000000000000..34bf920183d1 --- /dev/null +++ b/src/components/SelectionList/BaseListItem.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import RadioListItem from './RadioListItem'; +import {baseListItemPropTypes} from './selectionListPropTypes'; +import {BaseListItemProps} from './types'; +import UserListItem from './UserListItem'; + +function BaseListItem({ + item, + isFocused = false, + isDisabled = false, + showTooltip, + shouldPreventDefaultFocusOnSelectRow = false, + canSelectMultiple = false, + onSelectRow, + onDismissError = () => {}, + keyForList, +}: BaseListItemProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const isUserItem = !item.rightElement === undefined; + + return ( + onDismissError(item)} + pendingAction={item.pendingAction} + errors={item.errors} + errorRowStyles={styles.ph5} + > + onSelectRow(item)} + disabled={isDisabled} + accessibilityLabel={item.text} + role={CONST.ROLE.BUTTON} + hoverDimmingValue={1} + hoverStyle={styles.hoveredComponentBG} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} + testID={keyForList} + > + + {canSelectMultiple && ( + + + {item.isSelected && ( + + )} + + + )} + {item.rightElement === undefined ? ( + + ) : ( + + )} + {!canSelectMultiple && item.isSelected && ( + + + + + + )} + + {item.invitedSecondaryLogin && ( + + {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} + + )} + + + ); +} + +BaseListItem.displayName = 'BaseListItem'; +BaseListItem.propTypes = baseListItemPropTypes; + +export default BaseListItem; diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx new file mode 100644 index 000000000000..7712185b7f3e --- /dev/null +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -0,0 +1,525 @@ +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import React, {ForwardedRef, forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + GestureResponderEvent, + LayoutChangeEvent, + SectionList as RNSectionList, + TextInput as RNTextInput, + SectionListData, + SectionListRenderItemInfo, + View, + ViewStyle, +} from 'react-native'; +import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; +import Button from '@components/Button'; +import Checkbox from '@components/Checkbox'; +import FixedFooter from '@components/FixedFooter'; +import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import SafeAreaConsumer from '@components/SafeAreaConsumer'; +import SectionList from '@components/SectionList'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; +import useActiveElement from '@hooks/useActiveElement'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import {propTypes as selectionListPropTypes} from './selectionListPropTypes'; +import {BaseSelectionListProps, RadioItem, Section, User} from './types'; + +const propTypes = { + ...keyboardStatePropTypes, + ...selectionListPropTypes, +}; + +function BaseSelectionList( + { + sections, + canSelectMultiple = false, + onSelectRow, + onSelectAll, + onDismissError, + textInputLabel = '', + textInputPlaceholder = '', + textInputValue = '', + textInputMaxLength, + inputMode = CONST.INPUT_MODE.TEXT, + onChangeText, + initiallyFocusedOptionKey = '', + onScroll, + onScrollBeginDrag, + headerMessage = '', + confirmButtonText = '', + onConfirm = () => {}, + headerContent, + footerContent, + showScrollIndicator = false, + showLoadingPlaceholder = false, + showConfirmButton = false, + shouldPreventDefaultFocusOnSelectRow = false, + isKeyboardShown = false, + containerStyle = {}, + disableKeyboardShortcuts = false, + children, + shouldStopPropagation = false, + shouldUseDynamicMaxToRenderPerBatch = false, + }: BaseSelectionListProps, + inputRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const listRef = useRef>(null); + const textInputRef = useRef(null); + const focusTimeoutRef = useRef(null); + const shouldShowTextInput = !!textInputLabel; + const shouldShowSelectAll = !!onSelectAll; + const activeElement = useActiveElement(); + const isFocused = useIsFocused(); + const [maxToRenderPerBatch, setMaxToRenderPerBatch] = useState(shouldUseDynamicMaxToRenderPerBatch ? 0 : CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); + const [isInitialRender, setIsInitialRender] = useState(true); + const wrapperStyles = useMemo(() => ({opacity: isInitialRender ? 0 : 1}), [isInitialRender]); + + /** + * Iterates through the sections and items inside each section, and builds 3 arrays along the way: + * - `allOptions`: Contains all the items in the list, flattened, regardless of section + * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager + * - `itemLayouts`: Contains the layout information for each item, header and footer in the list, + * so we can calculate the position of any given item when scrolling programmatically + * + * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}} + */ + const flattenedSections = useMemo(() => { + const allOptions: Array = []; + + const disabledOptionsIndexes: number[] = []; + let disabledIndex = 0; + + let offset = 0; + const itemLayouts = [{length: 0, offset}]; + + const selectedOptions: Array = []; + + sections.forEach((section, sectionIndex) => { + const sectionHeaderHeight = variables.optionsListSectionHeaderHeight; + itemLayouts.push({length: sectionHeaderHeight, offset}); + offset += sectionHeaderHeight; + + section.data.forEach((item, optionIndex) => { + // Add item to the general flattened array + allOptions.push({ + ...item, + sectionIndex, + index: optionIndex, + }); + + // If disabled, add to the disabled indexes array + if (section.isDisabled ?? item.isDisabled) { + disabledOptionsIndexes.push(disabledIndex); + } + disabledIndex += 1; + + // Account for the height of the item in getItemLayout + const fullItemHeight = variables.optionRowHeight; + itemLayouts.push({length: fullItemHeight, offset}); + offset += fullItemHeight; + + if (item.isSelected) { + selectedOptions.push(item); + } + }); + + // We're not rendering any section footer, but we need to push to the array + // because React Native accounts for it in getItemLayout + itemLayouts.push({length: 0, offset}); + }); + + // We're not rendering the list footer, but we need to push to the array + // because React Native accounts for it in getItemLayout + itemLayouts.push({length: 0, offset}); + + if (selectedOptions.length > 1 && !canSelectMultiple) { + Log.alert( + 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.', + ); + } + + return { + allOptions, + selectedOptions, + disabledOptionsIndexes, + itemLayouts, + allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length, + }; + }, [canSelectMultiple, sections]); + + // If `initiallyFocusedOptionKey` is not passed, we fall back to `-1`, to avoid showing the highlight on the first member + const [focusedIndex, setFocusedIndex] = useState(() => flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey)); + + // Disable `Enter` shortcut if the active element is a button or checkbox + const disableEnterShortcut = activeElement.role && (activeElement.role === CONST.ROLE.BUTTON ?? activeElement.role === CONST.ROLE.CHECKBOX); + + /** + * Scrolls to the desired item index in the section list + * + * @param {Number} index - the index of the item to scroll to + * @param {Boolean} animated - whether to animate the scroll + */ + const scrollToIndex = useCallback( + (index: number, animated = true) => { + const item = flattenedSections.allOptions[index]; + + if (!listRef.current || !item) { + return; + } + + const itemIndex = item.index; + const sectionIndex = item.sectionIndex; + + // Note: react-native's SectionList automatically strips out any empty sections. + // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to. + // Otherwise, it will cause an index-out-of-bounds error and crash the app. + let adjustedSectionIndex = sectionIndex; + for (let i = 0; i < sectionIndex; i++) { + if (sections[i].data) { + adjustedSectionIndex--; + } + } + + listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight}); + }, + + // eslint-disable-next-line react-hooks/exhaustive-deps + [flattenedSections.allOptions], + ); + + /** + * Logic to run when a row is selected, either with click/press or keyboard hotkeys. + * + * @param item - the list item + * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) + */ + const selectRow = (item: RadioItem | User, shouldUnfocusRow = false) => { + // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item + if (canSelectMultiple) { + if (sections.length > 1) { + // If the list has only 1 section (e.g. Workspace Members list), we do nothing. + // If the list has multiple sections (e.g. Workspace Invite list), and `shouldUnfocusRow` is false, + // we focus the first one after all the selected (selected items are always at the top). + const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; + + if (!shouldUnfocusRow) { + setFocusedIndex(selectedOptionsCount); + } + + if (!item.isSelected) { + // If we're selecting an item, scroll to it's position at the top, so we can see it + scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); + } + } + + if (shouldUnfocusRow) { + // Unfocus all rows when selecting row with click/press + setFocusedIndex(-1); + } + } + + onSelectRow(item); + + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { + textInputRef.current.focus(); + } + }; + + const selectAllRow = () => { + if (onSelectAll) { + onSelectAll(); + } + if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow && textInputRef.current) { + textInputRef.current.focus(); + } + }; + + const selectFocusedOption = (e?: GestureResponderEvent | KeyboardEvent) => { + // const focusedItemKey = lodashGet(e, ['target', 'attributes', 'data-testid', 'value']); + const focusedItemKey = e?.target. + const focusedOption = focusedItemKey ? flattenedSections.allOptions.find((option) => option.keyForList === focusedItemKey) : flattenedSections.allOptions[focusedIndex]; + + if (!focusedOption || focusedOption.isDisabled) { + return; + } + + selectRow(focusedOption); + }; + + /** + * This function is used to compute the layout of any given item in our list. + * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. + * + * @param data - This is the same as the data we pass into the component + * @param flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: + * + * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. + * 2. Each section includes a header, even if we don't provide/render one. + * + * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: + * + * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] + * + * @returns + */ + const getItemLayout = (data: Array> | null, flatDataArrayIndex: number) => { + const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex]; + + if (!targetItem) { + return { + length: 0, + offset: 0, + index: flatDataArrayIndex, + }; + } + + return { + length: targetItem.length, + offset: targetItem.offset, + index: flatDataArrayIndex, + }; + }; + + const renderSectionHeader = ({section}: {section: SectionListData}) => { + if (!section.title || !section.data) { + return null; + } + + return ( + // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. + // We do this so that we can reference the height in `getItemLayout` – + // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. + // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. + + {section.title} + + ); + }; + + const renderItem = ({item, index, section}: SectionListRenderItemInfo) => { + const indexOffset = section.indexOffset ? section.indexOffset : 0; + const normalizedIndex = index + indexOffset; + const isDisabled = section.isDisabled ?? item.isDisabled; + const isItemFocused = !isDisabled && focusedIndex === normalizedIndex; + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. + const showTooltip = normalizedIndex < 10; + + return ( + selectRow(item, true)} + onDismissError={onDismissError} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + keyForList={item.keyForList} + /> + ); + }; + + const scrollToFocusedIndexOnFirstRender = useCallback( + (nativeEvent: LayoutChangeEvent) => { + if (shouldUseDynamicMaxToRenderPerBatch) { + // const listHeight = lodashGet(nativeEvent, 'layout.height', 0); + // const itemHeight = lodashGet(nativeEvent, 'layout.y', 0); + const listHeight = nativeEvent.nativeEvent.layout.height; + const itemHeight = nativeEvent.nativeEvent.layout.y; + setMaxToRenderPerBatch((Math.ceil(listHeight / itemHeight) || 0) + CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT); + } + + if (!isInitialRender) { + return; + } + scrollToIndex(focusedIndex, false); + setIsInitialRender(false); + }, + [focusedIndex, isInitialRender, scrollToIndex, shouldUseDynamicMaxToRenderPerBatch], + ); + + const updateAndScrollToFocusedIndex = useCallback( + (newFocusedIndex: number) => { + setFocusedIndex(newFocusedIndex); + scrollToIndex(newFocusedIndex, true); + }, + [scrollToIndex], + ); + + /** Focuses the text input when the component comes into focus and after any navigation animations finish. */ + useFocusEffect( + useCallback(() => { + if (shouldShowTextInput) { + focusTimeoutRef.current = setTimeout(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + }, CONST.ANIMATED_TRANSITION); + } + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }, [shouldShowTextInput]), + ); + + useEffect(() => { + // do not change focus on the first render, as it should focus on the selected item + if (isInitialRender) { + return; + } + + // set the focus on the first item when the sections list is changed + if (sections.length > 0) { + updateAndScrollToFocusedIndex(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections]); + + /** Selects row when pressing Enter */ + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + shouldStopPropagation, + isActive: !disableKeyboardShortcuts && !disableEnterShortcut && isFocused, + }); + + /** Calls confirm action when pressing CTRL (CMD) + Enter */ + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm, { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && Boolean(onConfirm) && isFocused, + }); + + return ( + + {/* */} + + {({safeAreaPaddingBottomStyle}) => ( + + {shouldShowTextInput && ( + + { + if (inputRef && typeof inputRef !== 'function') { + // eslint-disable-next-line no-param-reassign + inputRef.current = el; + } + textInputRef.current = el; + }} + label={textInputLabel} + accessibilityLabel={textInputLabel} + role={CONST.ROLE.PRESENTATION} + value={textInputValue} + placeholder={textInputPlaceholder} + maxLength={textInputMaxLength} + onChangeText={onChangeText} + inputMode={inputMode} + selectTextOnFocus + spellCheck={false} + onSubmitEditing={selectFocusedOption} + blurOnSubmit={Boolean(flattenedSections.allOptions.length)} + /> + + )} + {headerMessage && ( + + {headerMessage} + + )} + {headerContent} + {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? ( + + ) : ( + <> + {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( + e.preventDefault() : undefined} + > + + + {translate('workspace.people.selectAll')} + + + )} + item.keyForList} + extraData={focusedIndex} + indicatorStyle="white" + keyboardShouldPersistTaps="always" + showsVerticalScrollIndicator={showScrollIndicator} + initialNumToRender={12} + maxToRenderPerBatch={maxToRenderPerBatch} + windowSize={5} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}} + testID="selection-list" + onLayout={scrollToFocusedIndexOnFirstRender} + style={!maxToRenderPerBatch && styles.opacity0} + /> + {children} + + )} + {showConfirmButton && ( + +