Skip to content

Commit

Permalink
Merge pull request #44930 from Expensify/arosiclair-export-integratio…
Browse files Browse the repository at this point in the history
…n-action
  • Loading branch information
youssef-lr authored Jul 16, 2024
2 parents 825821b + 730a715 commit 8825b89
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 9 deletions.
4 changes: 4 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2093,8 +2093,12 @@ const CONST = {
NAME_USER_FRIENDLY: {
netsuite: 'NetSuite',
quickbooksOnline: 'Quickbooks Online',
quickbooksDesktop: 'Quickbooks Desktop',
xero: 'Xero',
intacct: 'Sage Intacct',
financialForce: 'FinancialForce',
billCom: 'Bill.com',
zenefits: 'Zenefits',
},
SYNC_STAGE_NAME: {
STARTING_IMPORT_QBO: 'startingImportQBO',
Expand Down
48 changes: 48 additions & 0 deletions src/components/ReportActionItem/ExportIntegration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* eslint-disable react/no-array-index-key */
import React from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ReportActionUtils from '@libs/ReportActionsUtils';
import type {ReportAction} from '@src/types/onyx';

type ExportIntegrationProps = {
action: OnyxEntry<ReportAction>;
};

function ExportIntegration({action}: ExportIntegrationProps) {
const styles = useThemeStyles();
const fragments = ReportActionUtils.getExportIntegrationActionFragments(action);

return (
<View style={[styles.flex1, styles.flexRow, styles.alignItemsCenter, styles.flexWrap]}>
{fragments.map((fragment, index) => {
if (!fragment.url) {
return (
<Text
key={index}
style={[styles.chatItemMessage, styles.colorMuted]}
>
{fragment.text}{' '}
</Text>
);
}

return (
<TextLink
key={index}
href={fragment.url}
>
{fragment.text}{' '}
</TextLink>
);
})}
</View>
);
}

ExportIntegration.displayName = 'ExportIntegration';

export default ExportIntegration;
8 changes: 7 additions & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3612,7 +3612,13 @@ export default {
changeType: ({oldType, newType}: ChangeTypeParams) => `changed type from ${oldType} to ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `sent this report to ${delegateUser} since ${originalManager} is on vacation`,
exportedToCSV: `exported this report to CSV`,
exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exported this report to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[label] ?? label}`,
exportedToIntegration: {
automatic: ({label}: ExportedToIntegrationParams) => `exported this report to ${label}.`,
manual: ({label}: ExportedToIntegrationParams) => `marked this report as manually exported to ${label}.`,
reimburseableLink: 'View out of pocket expenses.',
nonReimbursableLink: 'View company card expenses.',
pending: ({label}: ExportedToIntegrationParams) => `started exporting this report to ${label}...`,
},
forwarded: ({amount, currency}: ForwardedParams) => `approved ${currency}${amount}`,
integrationsMessage: (errorMessage: string, label: string) => `failed to export this report to ${label} ("${errorMessage}").`,
managerAttachReceipt: `added a receipt`,
Expand Down
8 changes: 7 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3668,7 +3668,13 @@ export default {
changeType: ({oldType, newType}: ChangeTypeParams) => `cambió type de ${oldType} a ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `envié este informe a ${delegateUser} ya que ${originalManager} está de vacaciones`,
exportedToCSV: `exportó este informe a CSV`,
exportedToIntegration: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}`,
exportedToIntegration: {
automatic: ({label}: ExportedToIntegrationParams) => `exportó este informe a ${label}.`,
manual: ({label}: ExportedToIntegrationParams) => `marcó este informe como exportado manualmente a ${label}.`,
reimburseableLink: 'Ver los gastos por cuenta propia.',
nonReimbursableLink: 'Ver los gastos de la tarjeta de empresa.',
pending: ({label}: ExportedToIntegrationParams) => `comenzó a exportar este informe a ${label}...`,
},
forwarded: ({amount, currency}: ForwardedParams) => `aprobado ${currency}${amount}`,
integrationsMessage: (errorMessage: string, label: string) => `no se pudo exportar este informe a ${label} ("${errorMessage}").`,
managerAttachReceipt: `agregó un recibo`,
Expand Down
4 changes: 2 additions & 2 deletions src/languages/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx';
import type {ConnectionName, Unit} from '@src/types/onyx/Policy';
import type {Unit} from '@src/types/onyx/Policy';
import type {ViolationDataType} from '@src/types/onyx/TransactionViolation';
import type en from './en';

Expand Down Expand Up @@ -311,7 +311,7 @@ type ChangeTypeParams = {oldType: string; newType: string};

type DelegateSubmitParams = {delegateUser: string; originalManager: string};

type ExportedToIntegrationParams = {label: ConnectionName};
type ExportedToIntegrationParams = {label: string};

type ForwardedParams = {amount: string; currency: string};

Expand Down
2 changes: 2 additions & 0 deletions src/libs/OptionsListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,8 @@ function getLastMessageTextForReport(report: OnyxEntry<Report>, lastActorDetails
lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(reportID);
} else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) {
lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction);
} else if (lastReportAction?.actionName === 'EXPORTINTEGRATION') {
lastMessageTextFromReport = ReportActionUtils.getExportIntegrationLastMessageText(lastReportAction);
} else if (lastReportAction?.actionName && ReportActionUtils.isOldDotReportAction(lastReportAction)) {
lastMessageTextFromReport = ReportActionUtils.getMessageOfOldDotReportAction(lastReportAction);
}
Expand Down
110 changes: 106 additions & 4 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {OnyxInputOrEntry} from '@src/types/onyx';
import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage';
import type {JoinWorkspaceResolution, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage';
import type Report from '@src/types/onyx/Report';
import type {Message, OldDotReportAction, OriginalMessage, ReportActions} from '@src/types/onyx/ReportAction';
import type ReportAction from '@src/types/onyx/ReportAction';
import type ReportActionName from '@src/types/onyx/ReportActionName';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import DateUtils from './DateUtils';
import * as Environment from './Environment/Environment';
import getBase62ReportID from './getBase62ReportID';
import isReportMessageAttachment from './isReportMessageAttachment';
import * as Localize from './Localize';
import Log from './Log';
Expand Down Expand Up @@ -82,6 +83,27 @@ Onyx.connect({
let environmentURL: string;
Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));

/*
* Url to the Xero non reimbursable expenses list
*/
const XERO_NON_REIMBURSABLE_EXPENSES_URL = 'https://go.xero.com/Bank/BankAccounts.aspx';

/*
* Url to the NetSuite global search, which should be suffixed with the reportID.
*/
const NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX =
'https://system.netsuite.com/app/common/search/ubersearchresults.nl?quicksearch=T&searchtype=Uber&frame=be&Uber_NAMEtype=KEYWORDSTARTSWITH&Uber_NAME=';

/*
* Url prefix to any Salesforce transaction or transaction list.
*/
const SALESFORCE_EXPENSES_URL_PREFIX = 'https://login.salesforce.com/';

/*
* Url to the QBO expenses list
*/
const QBO_EXPENSES_URL = 'https://qbo.intuit.com/app/expenses';

function isCreatedAction(reportAction: OnyxInputOrEntry<ReportAction>): boolean {
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
}
Expand Down Expand Up @@ -1156,7 +1178,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) {
CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE,
CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT,
CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV,
CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION,
CONST.REPORT.ACTIONS.TYPE.FORWARDED,
CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE,
CONST.REPORT.ACTIONS.TYPE.MANAGER_ATTACH_RECEIPT,
Expand Down Expand Up @@ -1220,8 +1241,6 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD
}
case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV:
return Localize.translateLocal('report.actions.type.exportedToCSV');
case CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION:
return Localize.translateLocal('report.actions.type.exportedToIntegration', {label: originalMessage.label});
case CONST.REPORT.ACTIONS.TYPE.INTEGRATIONS_MESSAGE: {
const {result, label} = originalMessage;
const errorMessage = result?.messages?.join(', ') ?? '';
Expand Down Expand Up @@ -1434,6 +1453,86 @@ function isActionableAddPaymentCard(reportAction: OnyxEntry<ReportAction>): repo
return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_ADD_PAYMENT_CARD;
}

function getExportIntegrationLastMessageText(reportAction: OnyxEntry<ReportAction>): string {
const fragments = getExportIntegrationActionFragments(reportAction);
return fragments.reduce((acc, fragment) => `${acc} ${fragment.text}`, '');
}

function getExportIntegrationMessageHTML(reportAction: OnyxEntry<ReportAction>): string {
const fragments = getExportIntegrationActionFragments(reportAction);
const htmlFragments = fragments.map((fragment) => (fragment.url ? `<a href="${fragment.url}">${fragment.text}</a>` : fragment.text));
return htmlFragments.join(' ');
}

function getExportIntegrationActionFragments(reportAction: OnyxEntry<ReportAction>): Array<{text: string; url: string}> {
if (reportAction?.actionName !== 'EXPORTINTEGRATION') {
throw Error(`received wrong action type. actionName: ${reportAction?.actionName}`);
}

const isPending = reportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD;
const originalMessage = (getOriginalMessage(reportAction) ?? {}) as OriginalMessageExportIntegration;
const {label, markedManually} = originalMessage;
const reimbursableUrls = originalMessage.reimbursableUrls ?? [];
const nonReimbursableUrls = originalMessage.nonReimbursableUrls ?? [];
const reportID = reportAction?.reportID ?? '';
const wasExportedAfterBase62 = (reportAction?.created ?? '') > '2022-11-14';
const base62ReportID = getBase62ReportID(Number(reportID));

const result: Array<{text: string; url: string}> = [];
if (isPending) {
result.push({
text: Localize.translateLocal('report.actions.type.exportedToIntegration.pending', {label}),
url: '',
});
} else if (markedManually) {
result.push({
text: Localize.translateLocal('report.actions.type.exportedToIntegration.manual', {label}),
url: '',
});
} else {
result.push({
text: Localize.translateLocal('report.actions.type.exportedToIntegration.automatic', {label}),
url: '',
});
}

if (reimbursableUrls.length === 1) {
result.push({
text: Localize.translateLocal('report.actions.type.exportedToIntegration.reimburseableLink'),
url: reimbursableUrls[0],
});
}

if (nonReimbursableUrls.length) {
const text = Localize.translateLocal('report.actions.type.exportedToIntegration.nonReimbursableLink');
let url = '';

if (nonReimbursableUrls.length === 1) {
url = nonReimbursableUrls[0];
} else {
switch (label) {
case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero:
url = XERO_NON_REIMBURSABLE_EXPENSES_URL;
break;
case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite:
url = NETSUITE_NON_REIMBURSABLE_EXPENSES_URL_PREFIX;
url += wasExportedAfterBase62 ? base62ReportID : reportID;
break;
case CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.financialForce:
// The first three characters in a Salesforce ID is the expense type
url = nonReimbursableUrls[0].substring(0, SALESFORCE_EXPENSES_URL_PREFIX.length + 3);
break;
default:
url = QBO_EXPENSES_URL;
}
}

result.push({text, url});
}

return result;
}

export {
extractLinksFromMessageHtml,
getDismissedViolationMessageText,
Expand Down Expand Up @@ -1522,6 +1621,9 @@ export {
getIOUActionForReportID,
getFilteredForOneTransactionView,
isActionableAddPaymentCard,
getExportIntegrationActionFragments,
getExportIntegrationLastMessageText,
getExportIntegrationMessageHTML,
};

export type {LastVisibleMessage};
19 changes: 19 additions & 0 deletions src/libs/getBase62ReportID.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Take an integer reportID and convert it to a string representing a Base-62 report ID.
*
* This is in it's own module to prevent a dependency cycle between libs/ReportUtils.ts and libs/ReportActionUtils.ts
*
* @return string The reportID in base 62-format, always 12 characters beginning with `R`.
*/
export default function getBase62ReportID(reportID: number): string {
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let result = '';
let remainder = reportID;
while (remainder > 0) {
const currentVal = remainder % 62;
result = alphabet[currentVal] + result;
remainder = Math.floor(remainder / 62);
}

return `R${result.padStart(11, '0')}`;
}
2 changes: 2 additions & 0 deletions src/pages/home/report/ContextMenu/ContextMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ const ContextMenuActions: ContextMenuAction[] = [
const reason = originalMessage?.reason;
const violationName = originalMessage?.violationName;
Clipboard.setString(Localize.translateLocal(`violationDismissal.${violationName}.${reason}` as TranslationPaths));
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) {
setClipboardMessage(ReportActionsUtils.getExportIntegrationMessageHTML(reportAction));
} else if (content) {
setClipboardMessage(
content.replace(/(<mention-user>)(.*?)(<\/mention-user>)/gi, (match, openTag: string, innerContent: string, closeTag: string): string => {
Expand Down
3 changes: 3 additions & 0 deletions src/pages/home/report/ReportActionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import RenderHTML from '@components/RenderHTML';
import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons';
import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons';
import ChronosOOOListActions from '@components/ReportActionItem/ChronosOOOListActions';
import ExportIntegration from '@components/ReportActionItem/ExportIntegration';
import MoneyRequestAction from '@components/ReportActionItem/MoneyRequestAction';
import RenameAction from '@components/ReportActionItem/RenameAction';
import ReportPreview from '@components/ReportActionItem/ReportPreview';
Expand Down Expand Up @@ -655,6 +656,8 @@ function ReportActionItem({
children = <ReportActionItemBasicMessage message={ReportActionsUtils.getDismissedViolationMessageText(ReportActionsUtils.getOriginalMessage(action))} />;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG) {
children = <ReportActionItemBasicMessage message={PolicyUtils.getCleanedTagName(ReportActionsUtils.getReportActionMessage(action)?.text ?? '')} />;
} else if (ReportActionsUtils.isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION)) {
children = <ExportIntegration action={action} />;
} else {
const hasBeenFlagged =
![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === moderationDecision) &&
Expand Down
38 changes: 37 additions & 1 deletion src/types/onyx/OriginalMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,41 @@ type OriginalMessageApproved = {
expenseReportID: string;
};

/**
*
*/
type OriginalMessageExportIntegration = {
/**
* Whether the export was done via an automation
*/
automaticAction: false;

/**
* The integration that was exported to (display text)
*/
label: string;

/**
*
*/
lastModified: string;

/**
* Whether the report was manually marked as exported
*/
markedManually: boolean;

/**
* An list of URLs to the report in the integration for company card expenses
*/
nonReimbursableUrls?: string[];

/**
* An list of URLs to the report in the integration for out of pocket expenses
*/
reimbursableUrls?: string[];
};

/** Model of `unapproved` report action */
type OriginalMessageUnapproved = {
/** Unapproved expense amount */
Expand Down Expand Up @@ -454,7 +489,7 @@ type OriginalMessageMap = {
/** */
[CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV]: never;
/** */
[CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION]: never;
[CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION]: OriginalMessageExportIntegration;
/** */
[CONST.REPORT.ACTIONS.TYPE.FORWARDED]: never;
/** */
Expand Down Expand Up @@ -562,4 +597,5 @@ export type {
OriginalMessageChangeLog,
JoinWorkspaceResolution,
OriginalMessageModifiedExpense,
OriginalMessageExportIntegration,
};

0 comments on commit 8825b89

Please sign in to comment.