snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
36
react/features/salesforce/actions.ts
Normal file
36
react/features/salesforce/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import { hideNotification, showNotification } from '../notifications/actions';
|
||||
import {
|
||||
NOTIFICATION_TIMEOUT_TYPE,
|
||||
NOTIFICATION_TYPE,
|
||||
SALESFORCE_LINK_NOTIFICATION_ID
|
||||
} from '../notifications/constants';
|
||||
|
||||
import { SalesforceLinkDialog } from './components';
|
||||
import { isSalesforceEnabled } from './functions';
|
||||
|
||||
/**
|
||||
* Displays the notification for linking the meeting to Salesforce.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function showSalesforceNotification() {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
if (!isSalesforceEnabled(getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'notify.linkToSalesforceDescription',
|
||||
titleKey: 'notify.linkToSalesforce',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
customActionNameKey: [ 'notify.linkToSalesforceKey' ],
|
||||
customActionHandler: [ () => {
|
||||
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
|
||||
dispatch(openDialog('SalesforceLinkDialog', SalesforceLinkDialog));
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
};
|
||||
}
|
||||
2
react/features/salesforce/components/index.native.ts
Normal file
2
react/features/salesforce/components/index.native.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-ignore
|
||||
export { default as SalesforceLinkDialog } from './native/SalesforceLinkDialog';
|
||||
1
react/features/salesforce/components/index.web.ts
Normal file
1
react/features/salesforce/components/index.web.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SalesforceLinkDialog } from './web/SalesforceLinkDialog';
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import {
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
IconRecordOpportunity
|
||||
} from '../../../base/icons/svg';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { formatAmount } from '../../functions';
|
||||
import { ISalesforceData, SalesforceObjectType } from '../../types';
|
||||
|
||||
import { RecordListItem } from './RecordListItem';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
onUnlink: (type: SalesforceObjectType, id: string) => void;
|
||||
salesforceData: ISalesforceData | null;
|
||||
unlinkingId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying currently linked Salesforce objects with unlink buttons.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const CurrentLinksSection = ({ salesforceData, unlinkingId, onUnlink }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasAccount = Boolean(salesforceData?.account);
|
||||
const hasLeads = salesforceData?.leads?.length;
|
||||
const hasContacts = salesforceData?.contacts?.length;
|
||||
const hasDeal = Boolean(salesforceData?.deal);
|
||||
const hasAnyLinks = hasAccount || hasLeads || hasContacts || hasDeal;
|
||||
|
||||
if (!hasAnyLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style = { styles.currentLinksSection as ViewStyle }>
|
||||
<Text style = { styles.sectionTitle }>{t('dialog.currentLinks')}</Text>
|
||||
|
||||
{salesforceData?.account && (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
compact = { true }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordAccount }
|
||||
isLoading = { unlinkingId === salesforceData.account.accountId }
|
||||
metadata = { t('record.type.account') }
|
||||
name = { salesforceData.account.accountName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onUnlink('Account', salesforceData.account!.accountId) } />
|
||||
)}
|
||||
|
||||
{salesforceData?.leads?.map(lead => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
compact = { true }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordLead }
|
||||
isLoading = { unlinkingId === lead.leadId }
|
||||
key = { lead.leadId }
|
||||
metadata = { `${t('record.type.lead')}${lead.leadCompany ? ` \u2022 ${lead.leadCompany}` : ''}` }
|
||||
name = { lead.leadName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onUnlink('Lead', lead.leadId) } />
|
||||
))}
|
||||
|
||||
{salesforceData?.contacts?.map(contact => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
compact = { true }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordContact }
|
||||
isLoading = { unlinkingId === contact.contactId }
|
||||
key = { contact.contactId }
|
||||
metadata = { t('record.type.contact') }
|
||||
name = { contact.contactName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onUnlink('Contact', contact.contactId) } />
|
||||
))}
|
||||
|
||||
{salesforceData?.deal && (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
compact = { true }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordOpportunity }
|
||||
isLoading = { unlinkingId === salesforceData.deal.opportunityId }
|
||||
metadata = { `${salesforceData.deal.opportunityStage}${
|
||||
salesforceData.deal.amount !== undefined
|
||||
? ` \u2022 $${formatAmount(salesforceData.deal.amount)}`
|
||||
: ''
|
||||
}` }
|
||||
name = { salesforceData.deal.opportunityName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onUnlink('Opportunity', salesforceData.deal!.opportunityId) } />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentLinksSection;
|
||||
137
react/features/salesforce/components/native/PendingSection.tsx
Normal file
137
react/features/salesforce/components/native/PendingSection.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { formatAmount } from '../../functions';
|
||||
import { IPendingAccount, IPendingDeal } from '../../types';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IPendingAccountsSectionProps {
|
||||
confirmingId: string | null;
|
||||
items: IPendingAccount[];
|
||||
onConfirm: (accountId: string) => void;
|
||||
onRejectAll: () => void;
|
||||
rejecting: boolean;
|
||||
type: 'accounts';
|
||||
}
|
||||
|
||||
interface IPendingDealsSectionProps {
|
||||
confirmingId: string | null;
|
||||
items: IPendingDeal[];
|
||||
onConfirm: (opportunityId: string) => void;
|
||||
onRejectAll: () => void;
|
||||
rejecting: boolean;
|
||||
type: 'deals';
|
||||
}
|
||||
|
||||
type IProps = IPendingAccountsSectionProps | IPendingDealsSectionProps;
|
||||
|
||||
/**
|
||||
* Component for displaying pending Salesforce suggestions (accounts or deals).
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const PendingSection = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { items, confirmingId, rejecting, onConfirm, onRejectAll, type } = props;
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAccounts = type === 'accounts';
|
||||
const title = isAccounts
|
||||
? t('dialog.suggestedAccounts')
|
||||
: t('dialog.suggestedDeals');
|
||||
const description = isAccounts
|
||||
? t('dialog.pendingDescription')
|
||||
: t('dialog.pendingDealsDescription');
|
||||
|
||||
return (
|
||||
<View style = { styles.section as ViewStyle }>
|
||||
<View style = { styles.pendingHeader as ViewStyle }>
|
||||
<Text style = { styles.sectionTitle }>{title}</Text>
|
||||
<Button
|
||||
disabled = { rejecting }
|
||||
labelKey = 'dialog.dismissAll'
|
||||
onClick = { onRejectAll }
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</View>
|
||||
<Text style = { styles.sectionDescription }>{description}</Text>
|
||||
|
||||
{isAccounts ? (
|
||||
(items as IPendingAccount[]).map(account => (
|
||||
<View
|
||||
key = { account.accountId }
|
||||
style = { styles.listItem as ViewStyle }>
|
||||
<View style = { styles.listItemInfo as ViewStyle }>
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.listItemName }>
|
||||
{account.accountName}
|
||||
</Text>
|
||||
{account.matchedEmailDomain && (
|
||||
<Text style = { styles.listItemMeta }>
|
||||
{account.matchedEmailDomain}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style = { styles.listItemButtonContainer as ViewStyle }>
|
||||
{confirmingId === account.accountId ? (
|
||||
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
|
||||
<LoadingIndicator size = 'small' />
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { confirmingId !== null }
|
||||
labelKey = 'dialog.confirm'
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => onConfirm(account.accountId) }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
(items as IPendingDeal[]).map(deal => (
|
||||
<View
|
||||
key = { deal.opportunityId }
|
||||
style = { styles.listItem as ViewStyle }>
|
||||
<View style = { styles.listItemInfo as ViewStyle }>
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.listItemName }>
|
||||
{deal.opportunityName}
|
||||
</Text>
|
||||
<Text style = { styles.listItemMeta }>
|
||||
{deal.opportunityStage}
|
||||
{deal.amount !== undefined && ` \u2022 $${formatAmount(deal.amount)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style = { styles.listItemButtonContainer as ViewStyle }>
|
||||
{confirmingId === deal.opportunityId ? (
|
||||
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
|
||||
<LoadingIndicator size = 'small' />
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { confirmingId !== null }
|
||||
labelKey = 'dialog.confirm'
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => onConfirm(deal.opportunityId) }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingSection;
|
||||
114
react/features/salesforce/components/native/RecordListItem.tsx
Normal file
114
react/features/salesforce/components/native/RecordListItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Translation key for the action button.
|
||||
*/
|
||||
actionLabelKey: string;
|
||||
|
||||
/**
|
||||
* Button type for the action button.
|
||||
*/
|
||||
actionType?: BUTTON_TYPES;
|
||||
|
||||
/**
|
||||
* Whether to use compact styling (no background, less padding).
|
||||
*/
|
||||
compact?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the action button should be disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The icon component to display.
|
||||
*/
|
||||
icon: Function;
|
||||
|
||||
/**
|
||||
* Whether this item's action is in progress.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Secondary text (type label, company, stage, etc.).
|
||||
*/
|
||||
metadata?: string;
|
||||
|
||||
/**
|
||||
* The primary display name.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Click handler for the action button.
|
||||
*/
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable list item component for Salesforce records with an action button.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const RecordListItem = ({
|
||||
actionLabelKey,
|
||||
actionType = BUTTON_TYPES.PRIMARY,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
icon,
|
||||
isLoading = false,
|
||||
metadata,
|
||||
name,
|
||||
onAction
|
||||
}: IProps) => {
|
||||
const itemStyle = compact ? styles.listItemCompact : styles.listItem;
|
||||
|
||||
return (
|
||||
<View style = { itemStyle as ViewStyle }>
|
||||
<View style = { styles.listItemIcon as ViewStyle }>
|
||||
<Icon
|
||||
size = { 24 }
|
||||
src = { icon } />
|
||||
</View>
|
||||
<View style = { styles.listItemInfo as ViewStyle }>
|
||||
<Text
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.listItemName }>
|
||||
{name}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<Text style = { styles.listItemMeta }>
|
||||
{metadata}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style = { styles.listItemButtonContainer as ViewStyle }>
|
||||
{isLoading ? (
|
||||
<View style = { styles.listItemSpinnerContainer as ViewStyle }>
|
||||
<LoadingIndicator size = 'small' />
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { disabled }
|
||||
labelKey = { actionLabelKey }
|
||||
onClick = { onAction }
|
||||
type = { actionType } />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordListItem;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollView, Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import { IconSearch } from '../../../base/icons/svg';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import Input from '../../../base/ui/components/native/Input';
|
||||
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
|
||||
|
||||
import { CurrentLinksSection } from './CurrentLinksSection';
|
||||
import { PendingSection } from './PendingSection';
|
||||
import { SearchResultsSection } from './SearchResultsSection';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Component that renders the Salesforce link dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SalesforceLinkDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
salesforceData,
|
||||
searchResults,
|
||||
hasSearchResults,
|
||||
hasPendingAccounts,
|
||||
hasPendingDeals,
|
||||
searchTerm,
|
||||
isLoading,
|
||||
isSearching,
|
||||
error,
|
||||
confirmingAccountId,
|
||||
confirmingDealId,
|
||||
rejectingAccounts,
|
||||
rejectingDeals,
|
||||
linkingId,
|
||||
unlinkingId,
|
||||
setSearchTerm,
|
||||
handleConfirmAccount,
|
||||
handleRejectAllAccounts,
|
||||
handleConfirmDeal,
|
||||
handleRejectAllDeals,
|
||||
handleLinkRecord,
|
||||
handleUnlinkRecord
|
||||
} = useSalesforceLinkDialog();
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
}, [ setSearchTerm ]);
|
||||
|
||||
const renderSearch = () => (
|
||||
<View style = { styles.searchContainer as ViewStyle }>
|
||||
<Input
|
||||
icon = { IconSearch }
|
||||
onChange = { handleSearchChange }
|
||||
placeholder = { t('dialog.searchInSalesforce') }
|
||||
value = { searchTerm } />
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style = { styles.spinnerContainer as ViewStyle }>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
contentContainerStyle = { styles.scrollContent }>
|
||||
{/* Search spinner */}
|
||||
{isSearching && (
|
||||
<View style = { styles.spinnerContainer as ViewStyle }>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search results */}
|
||||
{!isSearching && hasSearchResults && searchResults && (
|
||||
<SearchResultsSection
|
||||
linkingId = { linkingId }
|
||||
onLink = { handleLinkRecord }
|
||||
results = { searchResults } />
|
||||
)}
|
||||
|
||||
{/* Error (only show when not searching and no results) */}
|
||||
{!isSearching && !hasSearchResults && error && (
|
||||
<Text style = { styles.errorText }>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Pending accounts (when not searching) */}
|
||||
{!searchTerm && hasPendingAccounts && salesforceData?.pendingAccounts && (
|
||||
<PendingSection
|
||||
confirmingId = { confirmingAccountId }
|
||||
items = { salesforceData.pendingAccounts }
|
||||
onConfirm = { handleConfirmAccount }
|
||||
onRejectAll = { handleRejectAllAccounts }
|
||||
rejecting = { rejectingAccounts }
|
||||
type = 'accounts' />
|
||||
)}
|
||||
|
||||
{/* Pending deals (when not searching) */}
|
||||
{!searchTerm && hasPendingDeals && salesforceData?.pendingDeals && (
|
||||
<PendingSection
|
||||
confirmingId = { confirmingDealId }
|
||||
items = { salesforceData.pendingDeals }
|
||||
onConfirm = { handleConfirmDeal }
|
||||
onRejectAll = { handleRejectAllDeals }
|
||||
rejecting = { rejectingDeals }
|
||||
type = 'deals' />
|
||||
)}
|
||||
|
||||
{/* Current links */}
|
||||
<CurrentLinksSection
|
||||
onUnlink = { handleUnlinkRecord }
|
||||
salesforceData = { salesforceData }
|
||||
unlinkingId = { unlinkingId } />
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.salesforceDialogContainer as StyleType }>
|
||||
{renderSearch()}
|
||||
{renderContent()}
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesforceLinkDialog;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View, ViewStyle } from 'react-native';
|
||||
|
||||
import {
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
IconRecordOpportunity
|
||||
} from '../../../base/icons/svg';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { formatAmount } from '../../functions';
|
||||
import {
|
||||
IAccountMatch,
|
||||
IContactMatch,
|
||||
ILeadMatch,
|
||||
IOpportunityMatch,
|
||||
ISearchResults,
|
||||
SalesforceObjectType
|
||||
} from '../../types';
|
||||
|
||||
import { RecordListItem } from './RecordListItem';
|
||||
import styles from './styles';
|
||||
|
||||
interface IProps {
|
||||
linkingId: string | null;
|
||||
onLink: (type: SalesforceObjectType, data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch) => void;
|
||||
results: ISearchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying Salesforce search results with link buttons.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const SearchResultsSection = ({ results, linkingId, onLink }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasResults = results.accounts.length
|
||||
|| results.leads.length
|
||||
|| results.contacts.length
|
||||
|| results.opportunities.length;
|
||||
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<View style = { styles.noResultsContainer as ViewStyle }>
|
||||
<Text style = { styles.noResultsText }>
|
||||
{t('dialog.searchResultsNotFound')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{!!results.accounts.length && (
|
||||
<>
|
||||
<Text style = { styles.groupTitle }>{t('record.type.account')}</Text>
|
||||
{results.accounts.map(account => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordAccount }
|
||||
isLoading = { linkingId === account.accountId }
|
||||
key = { account.accountId }
|
||||
name = { account.accountName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onLink('Account', account) } />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.leads.length && (
|
||||
<>
|
||||
<Text style = { styles.groupTitle }>{t('record.type.lead')}</Text>
|
||||
{results.leads.map(lead => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordLead }
|
||||
isLoading = { linkingId === lead.leadId }
|
||||
key = { lead.leadId }
|
||||
metadata = { lead.leadCompany }
|
||||
name = { lead.leadName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onLink('Lead', lead) } />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.contacts.length && (
|
||||
<>
|
||||
<Text style = { styles.groupTitle }>{t('record.type.contact')}</Text>
|
||||
{results.contacts.map(contact => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordContact }
|
||||
isLoading = { linkingId === contact.contactId }
|
||||
key = { contact.contactId }
|
||||
metadata = { contact.contactEmail }
|
||||
name = { contact.contactName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onLink('Contact', contact) } />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.opportunities.length && (
|
||||
<>
|
||||
<Text style = { styles.groupTitle }>{t('record.type.opportunity')}</Text>
|
||||
{results.opportunities.map(opp => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordOpportunity }
|
||||
isLoading = { linkingId === opp.opportunityId }
|
||||
key = { opp.opportunityId }
|
||||
metadata = { `${opp.opportunityStage}${
|
||||
opp.amount !== undefined ? ` \u2022 $${formatAmount(opp.amount)}` : ''
|
||||
}` }
|
||||
name = { opp.opportunityName }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onAction = { () => onLink('Opportunity', opp) } />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultsSection;
|
||||
164
react/features/salesforce/components/native/styles.ts
Normal file
164
react/features/salesforce/components/native/styles.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { TextStyle, ViewStyle } from 'react-native';
|
||||
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
interface IStyles {
|
||||
currentLinksSection: ViewStyle;
|
||||
errorText: TextStyle;
|
||||
groupTitle: TextStyle;
|
||||
listItem: ViewStyle;
|
||||
listItemButtonContainer: ViewStyle;
|
||||
listItemCompact: ViewStyle;
|
||||
listItemIcon: ViewStyle;
|
||||
listItemInfo: ViewStyle;
|
||||
listItemMeta: TextStyle;
|
||||
listItemName: TextStyle;
|
||||
listItemSpinnerContainer: ViewStyle;
|
||||
noResultsContainer: ViewStyle;
|
||||
noResultsText: TextStyle;
|
||||
pendingHeader: ViewStyle;
|
||||
salesforceDialogContainer: ViewStyle;
|
||||
scrollContent: ViewStyle;
|
||||
searchContainer: ViewStyle;
|
||||
section: ViewStyle;
|
||||
sectionDescription: TextStyle;
|
||||
sectionTitle: TextStyle;
|
||||
spinnerContainer: ViewStyle;
|
||||
}
|
||||
|
||||
const styles: IStyles = {
|
||||
// Main container
|
||||
salesforceDialogContainer: {
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
// Search
|
||||
searchContainer: {
|
||||
alignSelf: 'stretch',
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
paddingHorizontal: BaseTheme.spacing[3],
|
||||
paddingTop: BaseTheme.spacing[2],
|
||||
paddingBottom: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
// Loading/spinner
|
||||
spinnerContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: BaseTheme.spacing[6]
|
||||
},
|
||||
|
||||
// Error
|
||||
errorText: {
|
||||
color: BaseTheme.palette.textError,
|
||||
textAlign: 'center',
|
||||
padding: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
// No results
|
||||
noResultsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: BaseTheme.spacing[4]
|
||||
},
|
||||
noResultsText: {
|
||||
color: BaseTheme.palette.text03,
|
||||
textAlign: 'center'
|
||||
},
|
||||
|
||||
// Section styles
|
||||
section: {
|
||||
marginBottom: BaseTheme.spacing[4],
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 13,
|
||||
color: BaseTheme.palette.text03,
|
||||
marginBottom: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
// Pending section header
|
||||
pendingHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
// Current links section
|
||||
currentLinksSection: {
|
||||
marginTop: BaseTheme.spacing[4],
|
||||
paddingTop: BaseTheme.spacing[3],
|
||||
paddingHorizontal: BaseTheme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BaseTheme.palette.ui05
|
||||
},
|
||||
|
||||
// Group title (for search results)
|
||||
groupTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: BaseTheme.palette.text03,
|
||||
textTransform: 'uppercase',
|
||||
marginTop: BaseTheme.spacing[3],
|
||||
marginBottom: BaseTheme.spacing[2],
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
// RecordListItem styles
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: BaseTheme.palette.ui03,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
padding: BaseTheme.spacing[3],
|
||||
marginBottom: BaseTheme.spacing[2],
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
listItemCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: BaseTheme.spacing[2]
|
||||
},
|
||||
listItemIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginRight: BaseTheme.spacing[3]
|
||||
},
|
||||
listItemInfo: {
|
||||
flex: 1
|
||||
},
|
||||
listItemName: {
|
||||
fontSize: 15,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
listItemMeta: {
|
||||
fontSize: 13,
|
||||
color: BaseTheme.palette.text03,
|
||||
marginTop: 2
|
||||
},
|
||||
listItemButtonContainer: {
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
},
|
||||
listItemSpinnerContainer: {
|
||||
width: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
// Scroll container
|
||||
scrollContent: {
|
||||
flexGrow: 1
|
||||
}
|
||||
};
|
||||
|
||||
export default styles;
|
||||
146
react/features/salesforce/components/web/CurrentLinksSection.tsx
Normal file
146
react/features/salesforce/components/web/CurrentLinksSection.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import {
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
IconRecordOpportunity
|
||||
} from '../../../base/icons/svg';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { ISalesforceData, SalesforceObjectType } from '../../types';
|
||||
|
||||
import { RecordListItem } from './RecordListItem';
|
||||
|
||||
interface IProps {
|
||||
onUnlink: (type: SalesforceObjectType, id: string) => void;
|
||||
salesforceData: ISalesforceData | null;
|
||||
unlinkingId: string | null;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
section: {
|
||||
marginTop: '24px',
|
||||
paddingTop: '16px',
|
||||
borderTop: `1px solid ${theme.palette.ui05}`
|
||||
},
|
||||
title: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 12px 0'
|
||||
},
|
||||
list: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component for displaying currently linked Salesforce objects with unlink buttons.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const CurrentLinksSection = ({ salesforceData, unlinkingId, onUnlink }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const hasAccount = Boolean(salesforceData?.account);
|
||||
const hasLeads = salesforceData?.leads?.length;
|
||||
const hasContacts = salesforceData?.contacts?.length;
|
||||
const hasDeal = Boolean(salesforceData?.deal);
|
||||
const hasAnyLinks = hasAccount || hasLeads || hasContacts || hasDeal;
|
||||
|
||||
const handleUnlinkAccount = useCallback((id: string) => {
|
||||
onUnlink('Account', id);
|
||||
}, [ onUnlink ]);
|
||||
|
||||
const handleUnlinkLead = useCallback((id: string) => {
|
||||
onUnlink('Lead', id);
|
||||
}, [ onUnlink ]);
|
||||
|
||||
const handleUnlinkContact = useCallback((id: string) => {
|
||||
onUnlink('Contact', id);
|
||||
}, [ onUnlink ]);
|
||||
|
||||
const handleUnlinkOpportunity = useCallback((id: string) => {
|
||||
onUnlink('Opportunity', id);
|
||||
}, [ onUnlink ]);
|
||||
|
||||
if (!hasAnyLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.section }>
|
||||
<h3 className = { classes.title }>{t('dialog.currentLinks')}</h3>
|
||||
<ul className = { classes.list }>
|
||||
{salesforceData?.account && (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordAccount }
|
||||
isLoading = { unlinkingId === salesforceData.account.accountId }
|
||||
key = { salesforceData.account.accountId }
|
||||
metadata = { t('record.type.account') }
|
||||
name = { salesforceData.account.accountName }
|
||||
onAction = { handleUnlinkAccount }
|
||||
record = { salesforceData.account.accountId } />
|
||||
)}
|
||||
|
||||
{salesforceData?.leads?.map(lead => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordLead }
|
||||
isLoading = { unlinkingId === lead.leadId }
|
||||
key = { lead.leadId }
|
||||
metadata = { `${t('record.type.lead')}${lead.leadCompany ? ` \u2022 ${lead.leadCompany}` : ''}` }
|
||||
name = { lead.leadName }
|
||||
onAction = { handleUnlinkLead }
|
||||
record = { lead.leadId } />
|
||||
))}
|
||||
|
||||
{salesforceData?.contacts?.map(contact => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordContact }
|
||||
isLoading = { unlinkingId === contact.contactId }
|
||||
key = { contact.contactId }
|
||||
metadata = { t('record.type.contact') }
|
||||
name = { contact.contactName }
|
||||
onAction = { handleUnlinkContact }
|
||||
record = { contact.contactId } />
|
||||
))}
|
||||
|
||||
{salesforceData?.deal && (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.unlink'
|
||||
actionType = { BUTTON_TYPES.TERTIARY }
|
||||
disabled = { unlinkingId !== null }
|
||||
icon = { IconRecordOpportunity }
|
||||
isLoading = { unlinkingId === salesforceData.deal.opportunityId }
|
||||
key = { salesforceData.deal.opportunityId }
|
||||
metadata = { `${salesforceData.deal.opportunityStage}${
|
||||
salesforceData.deal.amount !== undefined
|
||||
? ` \u2022 $${salesforceData.deal.amount.toLocaleString()}`
|
||||
: ''
|
||||
}` }
|
||||
name = { salesforceData.deal.opportunityName }
|
||||
onAction = { handleUnlinkOpportunity }
|
||||
record = { salesforceData.deal.opportunityId } />
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentLinksSection;
|
||||
198
react/features/salesforce/components/web/PendingSection.tsx
Normal file
198
react/features/salesforce/components/web/PendingSection.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Spinner from '../../../base/ui/components/web/Spinner';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import { IPendingAccount, IPendingDeal } from '../../types';
|
||||
|
||||
interface IPendingAccountsSectionProps {
|
||||
confirmingId: string | null;
|
||||
items: IPendingAccount[];
|
||||
onConfirm: (accountId: string) => void;
|
||||
onRejectAll: () => void;
|
||||
rejecting: boolean;
|
||||
type: 'accounts';
|
||||
}
|
||||
|
||||
interface IPendingDealsSectionProps {
|
||||
confirmingId: string | null;
|
||||
items: IPendingDeal[];
|
||||
onConfirm: (opportunityId: string) => void;
|
||||
onRejectAll: () => void;
|
||||
rejecting: boolean;
|
||||
type: 'deals';
|
||||
}
|
||||
|
||||
type IProps = IPendingAccountsSectionProps | IPendingDealsSectionProps;
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
section: {
|
||||
marginBottom: '24px'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
},
|
||||
title: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
margin: 0
|
||||
},
|
||||
description: {
|
||||
fontSize: '0.75rem',
|
||||
color: theme.palette.text03,
|
||||
marginBottom: '12px'
|
||||
},
|
||||
list: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: theme.palette.inputFieldBackground,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
marginBottom: '8px',
|
||||
|
||||
'&:last-child': {
|
||||
marginBottom: 0
|
||||
}
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
itemName: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
itemMeta: {
|
||||
fontSize: '0.75rem',
|
||||
color: theme.palette.text03,
|
||||
marginTop: '2px'
|
||||
},
|
||||
buttonContainer: {
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0
|
||||
},
|
||||
spinnerContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '70px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component for displaying pending Salesforce suggestions (accounts or deals).
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const PendingSection = (props: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const { items, confirmingId, rejecting, onConfirm, onRejectAll, type } = props;
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAccounts = type === 'accounts';
|
||||
const title = isAccounts
|
||||
? t('dialog.suggestedAccounts')
|
||||
: t('dialog.suggestedDeals');
|
||||
const description = isAccounts
|
||||
? t('dialog.pendingDescription')
|
||||
: t('dialog.pendingDealsDescription');
|
||||
|
||||
return (
|
||||
<div className = { classes.section }>
|
||||
<div className = { classes.header }>
|
||||
<h3 className = { classes.title }>{title}</h3>
|
||||
<Button
|
||||
disabled = { rejecting }
|
||||
labelKey = 'dialog.dismissAll'
|
||||
onClick = { onRejectAll }
|
||||
size = 'small'
|
||||
type = { BUTTON_TYPES.TERTIARY } />
|
||||
</div>
|
||||
<p className = { classes.description }>{description}</p>
|
||||
<ul className = { classes.list }>
|
||||
{isAccounts ? (
|
||||
(items as IPendingAccount[]).map(account => (
|
||||
<li
|
||||
className = { classes.item }
|
||||
key = { account.accountId }>
|
||||
<div className = { classes.itemInfo }>
|
||||
<div className = { classes.itemName }>{account.accountName}</div>
|
||||
{account.matchedEmailDomain && (
|
||||
<div className = { classes.itemMeta }>{account.matchedEmailDomain}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className = { classes.buttonContainer }>
|
||||
{confirmingId === account.accountId ? (
|
||||
<div className = { classes.spinnerContainer }>
|
||||
<Spinner size = 'small' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { confirmingId !== null }
|
||||
labelKey = 'dialog.confirm'
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => onConfirm(account.accountId) }
|
||||
size = 'small'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
(items as IPendingDeal[]).map(deal => (
|
||||
<li
|
||||
className = { classes.item }
|
||||
key = { deal.opportunityId }>
|
||||
<div className = { classes.itemInfo }>
|
||||
<div className = { classes.itemName }>{deal.opportunityName}</div>
|
||||
<div className = { classes.itemMeta }>
|
||||
{deal.opportunityStage}
|
||||
{deal.amount !== undefined && ` \u2022 $${deal.amount.toLocaleString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className = { classes.buttonContainer }>
|
||||
{confirmingId === deal.opportunityId ? (
|
||||
<div className = { classes.spinnerContainer }>
|
||||
<Spinner size = 'small' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { confirmingId !== null }
|
||||
labelKey = 'dialog.confirm'
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => onConfirm(deal.opportunityId) }
|
||||
size = 'small'
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingSection;
|
||||
160
react/features/salesforce/components/web/RecordListItem.tsx
Normal file
160
react/features/salesforce/components/web/RecordListItem.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Spinner from '../../../base/ui/components/web/Spinner';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
|
||||
interface IProps<T> {
|
||||
|
||||
/**
|
||||
* Translation key for the action button.
|
||||
*/
|
||||
actionLabelKey: string;
|
||||
|
||||
/**
|
||||
* Button type for the action button.
|
||||
*/
|
||||
actionType?: BUTTON_TYPES;
|
||||
|
||||
/**
|
||||
* Whether the action button should be disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The icon component to display.
|
||||
*/
|
||||
icon: React.ComponentType;
|
||||
|
||||
/**
|
||||
* Whether this item's action is in progress.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
|
||||
/**
|
||||
* Secondary text (type label, company, stage, etc.).
|
||||
*/
|
||||
metadata?: string;
|
||||
|
||||
/**
|
||||
* The primary display name.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Click handler for the action button.
|
||||
*/
|
||||
onAction: (record: T) => void;
|
||||
|
||||
/**
|
||||
* The record data to pass back when action is triggered.
|
||||
*/
|
||||
record: T;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: theme.palette.ui03,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
marginBottom: '6px',
|
||||
|
||||
'&:last-child': {
|
||||
marginBottom: 0
|
||||
}
|
||||
},
|
||||
iconContainer: {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
marginRight: '12px',
|
||||
flexShrink: 0,
|
||||
|
||||
'& svg': {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: theme.shape.borderRadius
|
||||
}
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
itemName: {
|
||||
fontSize: '0.875rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
itemMeta: {
|
||||
fontSize: '0.75rem',
|
||||
color: theme.palette.text03,
|
||||
marginTop: '2px'
|
||||
},
|
||||
buttonContainer: {
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0
|
||||
},
|
||||
spinnerContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '70px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable list item component for Salesforce records with an action button.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const RecordListItem = <T, >({
|
||||
actionLabelKey,
|
||||
actionType = BUTTON_TYPES.PRIMARY,
|
||||
disabled = false,
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
metadata,
|
||||
name,
|
||||
onAction,
|
||||
record
|
||||
}: IProps<T>) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
onAction(record);
|
||||
}, [ onAction, record ]);
|
||||
|
||||
return (
|
||||
<div className = { classes.item }>
|
||||
<div className = { classes.iconContainer }>
|
||||
<Icon />
|
||||
</div>
|
||||
<div className = { classes.itemInfo }>
|
||||
<div className = { classes.itemName }>{name}</div>
|
||||
{metadata && <div className = { classes.itemMeta }>{metadata}</div>}
|
||||
</div>
|
||||
<div className = { classes.buttonContainer }>
|
||||
{isLoading ? (
|
||||
<div className = { classes.spinnerContainer }>
|
||||
<Spinner size = 'small' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
disabled = { disabled }
|
||||
labelKey = { actionLabelKey }
|
||||
onClick = { handleAction }
|
||||
size = 'small'
|
||||
type = { actionType } />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecordListItem;
|
||||
@@ -0,0 +1,218 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { hideDialog } from '../../../base/dialog/actions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { IconSearch } from '../../../base/icons/svg';
|
||||
import { getFieldValue } from '../../../base/react/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
import Spinner from '../../../base/ui/components/web/Spinner';
|
||||
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
|
||||
|
||||
import { CurrentLinksSection } from './CurrentLinksSection';
|
||||
import { PendingSection } from './PendingSection';
|
||||
import { SearchResultsSection } from './SearchResultsSection';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
minHeight: '400px',
|
||||
maxHeight: '500px',
|
||||
overflowY: 'auto',
|
||||
position: 'relative'
|
||||
},
|
||||
searchContainer: {
|
||||
position: 'relative',
|
||||
padding: '1px',
|
||||
marginBottom: '16px'
|
||||
},
|
||||
searchIcon: {
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
color: theme.palette.salesforceSearchIcon,
|
||||
left: 16,
|
||||
top: 10,
|
||||
width: 20,
|
||||
height: 20
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: theme.palette.salesforceSearchBackground,
|
||||
border: '1px solid',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.salesforceSearchBorder,
|
||||
color: theme.palette.salesforceSearchText,
|
||||
padding: '10px 16px 10px 44px',
|
||||
width: '100%',
|
||||
height: 40,
|
||||
'&::placeholder': {
|
||||
color: theme.palette.salesforceSearchPlaceholder,
|
||||
...theme.typography.bodyShortRegular
|
||||
}
|
||||
},
|
||||
spinner: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 0',
|
||||
boxSizing: 'border-box'
|
||||
},
|
||||
error: {
|
||||
textAlign: 'center',
|
||||
color: theme.palette.textError,
|
||||
padding: '24px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Component that renders the Salesforce link dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function SalesforceLinkDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { classes, theme } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
salesforceData,
|
||||
searchResults,
|
||||
hasSearchResults,
|
||||
hasPendingAccounts,
|
||||
hasPendingDeals,
|
||||
searchTerm,
|
||||
isLoading,
|
||||
isSearching,
|
||||
error,
|
||||
confirmingAccountId,
|
||||
confirmingDealId,
|
||||
rejectingAccounts,
|
||||
rejectingDeals,
|
||||
linkingId,
|
||||
unlinkingId,
|
||||
setSearchTerm,
|
||||
handleConfirmAccount,
|
||||
handleRejectAllAccounts,
|
||||
handleConfirmDeal,
|
||||
handleRejectAllDeals,
|
||||
handleLinkRecord,
|
||||
handleUnlinkRecord
|
||||
} = useSalesforceLinkDialog();
|
||||
|
||||
const handleSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = getFieldValue(event);
|
||||
|
||||
setSearchTerm(value);
|
||||
}, [ setSearchTerm ]);
|
||||
|
||||
const handleSearchKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(hideDialog());
|
||||
}, [ dispatch ]);
|
||||
|
||||
const renderSearch = () => (
|
||||
<div className = { classes.searchContainer }>
|
||||
<Icon
|
||||
className = { classes.searchIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSearch } />
|
||||
<input
|
||||
autoComplete = 'off'
|
||||
autoFocus = { true }
|
||||
className = { classes.searchInput }
|
||||
name = 'salesforceSearch'
|
||||
onChange = { handleSearchChange }
|
||||
onKeyDown = { handleSearchKeyDown }
|
||||
placeholder = { t('dialog.searchInSalesforce') }
|
||||
tabIndex = { 0 }
|
||||
value = { searchTerm } />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className = { classes.spinner }>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search spinner */}
|
||||
{isSearching && (
|
||||
<div className = { classes.spinner }>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results */}
|
||||
{!isSearching && hasSearchResults && searchResults && (
|
||||
<SearchResultsSection
|
||||
linkingId = { linkingId }
|
||||
onLink = { handleLinkRecord }
|
||||
results = { searchResults } />
|
||||
)}
|
||||
|
||||
{/* Error (only show when not searching and no results) */}
|
||||
{!isSearching && !hasSearchResults && error && (
|
||||
<div className = { classes.error }>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending suggestions (when not searching) */}
|
||||
{!searchTerm && hasPendingAccounts && salesforceData?.pendingAccounts && (
|
||||
<PendingSection
|
||||
confirmingId = { confirmingAccountId }
|
||||
items = { salesforceData.pendingAccounts }
|
||||
onConfirm = { handleConfirmAccount }
|
||||
onRejectAll = { handleRejectAllAccounts }
|
||||
rejecting = { rejectingAccounts }
|
||||
type = 'accounts' />
|
||||
)}
|
||||
|
||||
{!searchTerm && hasPendingDeals && salesforceData?.pendingDeals && (
|
||||
<PendingSection
|
||||
confirmingId = { confirmingDealId }
|
||||
items = { salesforceData.pendingDeals }
|
||||
onConfirm = { handleConfirmDeal }
|
||||
onRejectAll = { handleRejectAllDeals }
|
||||
rejecting = { rejectingDeals }
|
||||
type = 'deals' />
|
||||
)}
|
||||
|
||||
{/* Current links */}
|
||||
<CurrentLinksSection
|
||||
onUnlink = { handleUnlinkRecord }
|
||||
salesforceData = { salesforceData }
|
||||
unlinkingId = { unlinkingId } />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
cancel = {{ hidden: true }}
|
||||
disableEnter = { true }
|
||||
ok = {{ hidden: true }}
|
||||
onCancel = { handleClose }
|
||||
titleKey = 'dialog.linkMeetingTitle'>
|
||||
<div className = { classes.container }>
|
||||
{renderSearch()}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SalesforceLinkDialog;
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import {
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
IconRecordOpportunity
|
||||
} from '../../../base/icons/svg';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
|
||||
import {
|
||||
IAccountMatch,
|
||||
IContactMatch,
|
||||
ILeadMatch,
|
||||
IOpportunityMatch,
|
||||
ISearchResults,
|
||||
SalesforceObjectType
|
||||
} from '../../types';
|
||||
|
||||
import { RecordListItem } from './RecordListItem';
|
||||
|
||||
interface IProps {
|
||||
linkingId: string | null;
|
||||
onLink: (type: SalesforceObjectType, data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch) => void;
|
||||
results: ISearchResults;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
section: {
|
||||
marginBottom: '16px'
|
||||
},
|
||||
groupTitle: {
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: theme.palette.text03,
|
||||
textTransform: 'uppercase',
|
||||
margin: '16px 0 8px 0'
|
||||
},
|
||||
list: {
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0
|
||||
},
|
||||
noResults: {
|
||||
textAlign: 'center',
|
||||
color: theme.palette.text03,
|
||||
padding: '24px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component for displaying Salesforce search results with link buttons.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {React.ReactElement}
|
||||
*/
|
||||
export const SearchResultsSection = ({ results, linkingId, onLink }: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const hasResults = results.accounts.length
|
||||
|| results.leads.length
|
||||
|| results.contacts.length
|
||||
|| results.opportunities.length;
|
||||
|
||||
const handleLinkAccount = useCallback((account: IAccountMatch) => {
|
||||
onLink('Account', account);
|
||||
}, [ onLink ]);
|
||||
|
||||
const handleLinkLead = useCallback((lead: ILeadMatch) => {
|
||||
onLink('Lead', lead);
|
||||
}, [ onLink ]);
|
||||
|
||||
const handleLinkContact = useCallback((contact: IContactMatch) => {
|
||||
onLink('Contact', contact);
|
||||
}, [ onLink ]);
|
||||
|
||||
const handleLinkOpportunity = useCallback((opportunity: IOpportunityMatch) => {
|
||||
onLink('Opportunity', opportunity);
|
||||
}, [ onLink ]);
|
||||
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<div className = { classes.noResults }>
|
||||
{t('dialog.searchResultsNotFound')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.section }>
|
||||
{!!results.accounts.length && (
|
||||
<>
|
||||
<h4 className = { classes.groupTitle }>{t('record.type.account')}</h4>
|
||||
<ul className = { classes.list }>
|
||||
{results.accounts.map(account => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordAccount }
|
||||
isLoading = { linkingId === account.accountId }
|
||||
key = { account.accountId }
|
||||
name = { account.accountName }
|
||||
onAction = { handleLinkAccount }
|
||||
record = { account } />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.leads.length && (
|
||||
<>
|
||||
<h4 className = { classes.groupTitle }>{t('record.type.lead')}</h4>
|
||||
<ul className = { classes.list }>
|
||||
{results.leads.map(lead => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordLead }
|
||||
isLoading = { linkingId === lead.leadId }
|
||||
key = { lead.leadId }
|
||||
metadata = { lead.leadCompany }
|
||||
name = { lead.leadName }
|
||||
onAction = { handleLinkLead }
|
||||
record = { lead } />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.contacts.length && (
|
||||
<>
|
||||
<h4 className = { classes.groupTitle }>{t('record.type.contact')}</h4>
|
||||
<ul className = { classes.list }>
|
||||
{results.contacts.map(contact => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordContact }
|
||||
isLoading = { linkingId === contact.contactId }
|
||||
key = { contact.contactId }
|
||||
metadata = { contact.contactEmail }
|
||||
name = { contact.contactName }
|
||||
onAction = { handleLinkContact }
|
||||
record = { contact } />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!results.opportunities.length && (
|
||||
<>
|
||||
<h4 className = { classes.groupTitle }>{t('record.type.opportunity')}</h4>
|
||||
<ul className = { classes.list }>
|
||||
{results.opportunities.map(opp => (
|
||||
<RecordListItem
|
||||
actionLabelKey = 'dialog.link'
|
||||
actionType = { BUTTON_TYPES.PRIMARY }
|
||||
disabled = { linkingId !== null }
|
||||
icon = { IconRecordOpportunity }
|
||||
isLoading = { linkingId === opp.opportunityId }
|
||||
key = { opp.opportunityId }
|
||||
metadata = { `${opp.opportunityStage}${
|
||||
opp.amount !== undefined ? ` \u2022 $${opp.amount.toLocaleString()}` : ''
|
||||
}` }
|
||||
name = { opp.opportunityName }
|
||||
onAction = { handleLinkOpportunity }
|
||||
record = { opp } />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResultsSection;
|
||||
41
react/features/salesforce/constants.ts
Normal file
41
react/features/salesforce/constants.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
IconRecordOpportunity
|
||||
} from '../base/icons/svg';
|
||||
|
||||
export const NOTES_MAX_LENGTH = 255;
|
||||
|
||||
export const NOTES_LINES = 4;
|
||||
|
||||
export const CONTENT_HEIGHT_OFFSET = 200;
|
||||
|
||||
export const LIST_HEIGHT_OFFSET = 250;
|
||||
|
||||
export const RECORD_TYPE: {
|
||||
[key: string]: {
|
||||
icon?: Function;
|
||||
label: string;
|
||||
};
|
||||
} = {
|
||||
ACCOUNT: {
|
||||
label: 'record.type.account',
|
||||
icon: IconRecordAccount
|
||||
},
|
||||
CONTACT: {
|
||||
label: 'record.type.contact',
|
||||
icon: IconRecordContact
|
||||
},
|
||||
LEAD: {
|
||||
label: 'record.type.lead',
|
||||
icon: IconRecordLead
|
||||
},
|
||||
OPPORTUNITY: {
|
||||
label: 'record.type.opportunity',
|
||||
icon: IconRecordOpportunity
|
||||
},
|
||||
OWNER: {
|
||||
label: 'record.type.owner'
|
||||
}
|
||||
};
|
||||
228
react/features/salesforce/functions.ts
Normal file
228
react/features/salesforce/functions.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
import {
|
||||
IAccountMatch,
|
||||
IConfirmAccountResult,
|
||||
IConfirmDealResult,
|
||||
IContactMatch,
|
||||
ILeadMatch,
|
||||
ILinkResult,
|
||||
IOpportunityMatch,
|
||||
ISalesforceData,
|
||||
ISearchResults,
|
||||
SalesforceObjectType
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Formats a number with thousand separators.
|
||||
* Uses a regex approach that works reliably in React Native
|
||||
* (toLocaleString has known issues on Android and iOS with Hermes).
|
||||
*
|
||||
* @param {number} amount - The number to format.
|
||||
* @returns {string} The formatted number string.
|
||||
*/
|
||||
export function formatAmount(amount: number): string {
|
||||
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether Salesforce is enabled for the current conference.
|
||||
*
|
||||
* @param {IReduxState} state - The redux store, the redux
|
||||
* {@code getState} function, or the redux state itself.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isSalesforceEnabled = (state: IReduxState) => {
|
||||
const { salesforceUrl } = state['features/base/config'];
|
||||
const isBreakoutRoom = isInBreakoutRoom(state);
|
||||
|
||||
return Boolean(salesforceUrl) && !isBreakoutRoom;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to make authenticated requests to the meet-metrics API.
|
||||
*
|
||||
* @param {string} url - The full URL to fetch.
|
||||
* @param {string} jwt - The JWT token for authentication.
|
||||
* @param {RequestInit} options - Additional fetch options.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function fetchWithAuth(url: string, jwt: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return res.ok ? json : Promise.reject(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Salesforce data for a session including current links and pending suggestions.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @returns {Promise<ISalesforceData>}
|
||||
*/
|
||||
export async function getSessionSalesforceData(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string
|
||||
): Promise<ISalesforceData> {
|
||||
const response = await fetchWithAuth(`${url}/api/sessions/crm`, jwt, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionIds: [ sessionId ] })
|
||||
});
|
||||
|
||||
// Extract data for this specific session from the batch response
|
||||
const sessionData = response?.crmDataBySession?.[sessionId]?.salesforce;
|
||||
|
||||
return sessionData || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches Salesforce objects (Accounts, Leads, Contacts, Opportunities).
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} query - The search query (minimum 2 characters).
|
||||
* @returns {Promise<ISearchResults>}
|
||||
*/
|
||||
export async function searchSalesforce(
|
||||
url: string,
|
||||
jwt: string,
|
||||
query: string
|
||||
): Promise<ISearchResults> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/search?q=${encodeURIComponent(query)}`, jwt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a session to a Salesforce object.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @param {SalesforceObjectType} type - The type of Salesforce object.
|
||||
* @param {Object} data - The match data for the object.
|
||||
* @returns {Promise<ILinkResult>}
|
||||
*/
|
||||
export async function linkSession(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string,
|
||||
type: SalesforceObjectType,
|
||||
data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch
|
||||
): Promise<ILinkResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/link`, jwt, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, data })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a pending account match and auto-links the best opportunity.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @param {string} accountId - The Salesforce Account ID to confirm.
|
||||
* @returns {Promise<IConfirmAccountResult>}
|
||||
*/
|
||||
export async function confirmPendingAccount(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string,
|
||||
accountId: string
|
||||
): Promise<IConfirmAccountResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/confirm-account`, jwt, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountId })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects all pending account matches for a session.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @returns {Promise<ILinkResult>}
|
||||
*/
|
||||
export async function rejectPendingAccounts(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string
|
||||
): Promise<ILinkResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/pending-accounts`, jwt, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a pending deal match.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @param {string} opportunityId - The Salesforce Opportunity ID to confirm.
|
||||
* @returns {Promise<IConfirmDealResult>}
|
||||
*/
|
||||
export async function confirmPendingDeal(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string,
|
||||
opportunityId: string
|
||||
): Promise<IConfirmDealResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/confirm-deal`, jwt, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ opportunityId })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects all pending deal matches for a session.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @returns {Promise<ILinkResult>}
|
||||
*/
|
||||
export async function rejectPendingDeals(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string
|
||||
): Promise<ILinkResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/pending-deals`, jwt, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks a Salesforce object from a session.
|
||||
*
|
||||
* @param {string} url - The base URL for the meet-metrics API.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The meeting session ID.
|
||||
* @param {SalesforceObjectType} type - The type of Salesforce object to unlink.
|
||||
* @param {string} id - The Salesforce object ID to unlink.
|
||||
* @returns {Promise<ILinkResult>}
|
||||
*/
|
||||
export async function unlinkSession(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: string,
|
||||
type: SalesforceObjectType,
|
||||
id: string
|
||||
): Promise<ILinkResult> {
|
||||
return fetchWithAuth(`${url}/api/salesforce/sessions/${sessionId}/link`, jwt, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ type, id })
|
||||
});
|
||||
}
|
||||
166
react/features/salesforce/types.ts
Normal file
166
react/features/salesforce/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Pending account suggestion awaiting user confirmation.
|
||||
*/
|
||||
export interface IPendingAccount {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
matchConfidence: number;
|
||||
matchedEmailDomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending deal/opportunity suggestion awaiting user confirmation.
|
||||
*/
|
||||
export interface IPendingDeal {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
amount?: number;
|
||||
closeDate?: string;
|
||||
isClosed?: boolean;
|
||||
matchConfidence: number;
|
||||
opportunityId: string;
|
||||
opportunityName: string;
|
||||
opportunityStage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked account information.
|
||||
*/
|
||||
export interface ILinkedAccount {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked lead information.
|
||||
*/
|
||||
export interface ILinkedLead {
|
||||
leadCompany?: string;
|
||||
leadId: string;
|
||||
leadName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked contact information.
|
||||
*/
|
||||
export interface ILinkedContact {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linked deal/opportunity information.
|
||||
*/
|
||||
export interface ILinkedDeal {
|
||||
amount?: number;
|
||||
closeDate?: string;
|
||||
isClosed?: boolean;
|
||||
isWon?: boolean;
|
||||
opportunityId: string;
|
||||
opportunityName: string;
|
||||
opportunityStage: string;
|
||||
probability?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Salesforce data for a session including current links and pending suggestions.
|
||||
*/
|
||||
export interface ISalesforceData {
|
||||
account?: ILinkedAccount;
|
||||
contacts?: ILinkedContact[];
|
||||
deal?: ILinkedDeal;
|
||||
leads?: ILinkedLead[];
|
||||
pendingAccounts?: IPendingAccount[];
|
||||
pendingDeals?: IPendingDeal[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Account match from search results.
|
||||
*/
|
||||
export interface IAccountMatch {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
matchConfidence: number;
|
||||
matchedEmailDomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead match from search results.
|
||||
*/
|
||||
export interface ILeadMatch {
|
||||
leadCompany?: string;
|
||||
leadEmail: string;
|
||||
leadId: string;
|
||||
leadName: string;
|
||||
matchConfidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact match from search results.
|
||||
*/
|
||||
export interface IContactMatch {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
contactEmail: string;
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
matchConfidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunity match from search results.
|
||||
*/
|
||||
export interface IOpportunityMatch {
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
amount?: number;
|
||||
closeDate?: string;
|
||||
isClosed?: boolean;
|
||||
matchConfidence: number;
|
||||
opportunityId: string;
|
||||
opportunityName: string;
|
||||
opportunityStage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified search results from Salesforce.
|
||||
*/
|
||||
export interface ISearchResults {
|
||||
accounts: IAccountMatch[];
|
||||
contacts: IContactMatch[];
|
||||
leads: ILeadMatch[];
|
||||
opportunities: IOpportunityMatch[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Salesforce object type for API calls.
|
||||
*/
|
||||
export type SalesforceObjectType = 'Account' | 'Lead' | 'Contact' | 'Opportunity';
|
||||
|
||||
/**
|
||||
* Link result from API.
|
||||
*/
|
||||
export interface ILinkResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm pending account result.
|
||||
*/
|
||||
export interface IConfirmAccountResult {
|
||||
error?: string;
|
||||
linkedOpportunity?: {
|
||||
opportunityId: string;
|
||||
opportunityName: string;
|
||||
};
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm pending deal result.
|
||||
*/
|
||||
export interface IConfirmDealResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
424
react/features/salesforce/useSalesforceLinkDialog.ts
Normal file
424
react/features/salesforce/useSalesforceLinkDialog.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { debounce } from 'lodash-es';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../app/types';
|
||||
import { getCurrentConference } from '../base/conference/functions';
|
||||
import { hideNotification, showNotification } from '../notifications/actions';
|
||||
import {
|
||||
NOTIFICATION_TIMEOUT_TYPE,
|
||||
NOTIFICATION_TYPE,
|
||||
SALESFORCE_LINK_NOTIFICATION_ID
|
||||
} from '../notifications/constants';
|
||||
|
||||
import {
|
||||
confirmPendingAccount,
|
||||
confirmPendingDeal,
|
||||
getSessionSalesforceData,
|
||||
linkSession,
|
||||
rejectPendingAccounts,
|
||||
rejectPendingDeals,
|
||||
searchSalesforce,
|
||||
unlinkSession
|
||||
} from './functions';
|
||||
import {
|
||||
IAccountMatch,
|
||||
IContactMatch,
|
||||
ILeadMatch,
|
||||
IOpportunityMatch,
|
||||
ISalesforceData,
|
||||
ISearchResults,
|
||||
SalesforceObjectType
|
||||
} from './types';
|
||||
|
||||
export const useSalesforceLinkDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux state
|
||||
const conference = useSelector(getCurrentConference);
|
||||
const sessionId = conference?.getMeetingUniqueId();
|
||||
const { salesforceUrl = '' } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { jwt = '' } = useSelector((state: IReduxState) => state['features/base/jwt']);
|
||||
|
||||
// Component state
|
||||
const [ salesforceData, setSalesforceData ] = useState<ISalesforceData | null>(null);
|
||||
const [ searchResults, setSearchResults ] = useState<ISearchResults | null>(null);
|
||||
const [ searchTerm, setSearchTerm ] = useState('');
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ isSearching, setIsSearching ] = useState(false);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
|
||||
// Loading states for actions
|
||||
const [ confirmingAccountId, setConfirmingAccountId ] = useState<string | null>(null);
|
||||
const [ confirmingDealId, setConfirmingDealId ] = useState<string | null>(null);
|
||||
const [ rejectingAccounts, setRejectingAccounts ] = useState(false);
|
||||
const [ rejectingDeals, setRejectingDeals ] = useState(false);
|
||||
const [ linkingId, setLinkingId ] = useState<string | null>(null);
|
||||
const [ unlinkingId, setUnlinkingId ] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Refreshes the Salesforce data for the current session.
|
||||
*/
|
||||
const refreshSalesforceData = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getSessionSalesforceData(salesforceUrl, jwt, sessionId);
|
||||
|
||||
setSalesforceData(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch Salesforce data:', err);
|
||||
setError(err?.error || t('dialog.salesforceDataError'));
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, t ]);
|
||||
|
||||
/**
|
||||
* Performs a search in Salesforce.
|
||||
*/
|
||||
const performSearch = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
|
||||
try {
|
||||
const results = await searchSalesforce(salesforceUrl, jwt, query);
|
||||
|
||||
setSearchResults(results);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
console.error('Salesforce search failed:', err);
|
||||
setError(err?.error || t('dialog.searchResultsError'));
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, t ]);
|
||||
|
||||
// Debounced search
|
||||
const debouncedSearch = useMemo(
|
||||
() => debounce(performSearch, 500),
|
||||
[ performSearch ]
|
||||
);
|
||||
|
||||
// Track if component is mounted
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch Salesforce data on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await refreshSalesforceData();
|
||||
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [ refreshSalesforceData ]);
|
||||
|
||||
// Trigger search when search term changes
|
||||
useEffect(() => {
|
||||
if (searchTerm.length >= 2) {
|
||||
debouncedSearch(searchTerm);
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
}, [ searchTerm, debouncedSearch ]);
|
||||
|
||||
/**
|
||||
* Handles confirming a pending account.
|
||||
*/
|
||||
const handleConfirmAccount = useCallback(async (accountId: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmingAccountId(accountId);
|
||||
|
||||
try {
|
||||
const result = await confirmPendingAccount(salesforceUrl, jwt, sessionId, accountId);
|
||||
|
||||
if (result.success) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.confirmAccountSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
await refreshSalesforceData();
|
||||
}
|
||||
} catch (err: any) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.confirmAccountError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setConfirmingAccountId(null);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
/**
|
||||
* Handles rejecting all pending accounts.
|
||||
*/
|
||||
const handleRejectAllAccounts = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRejectingAccounts(true);
|
||||
|
||||
try {
|
||||
await rejectPendingAccounts(salesforceUrl, jwt, sessionId);
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.dismissSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
await refreshSalesforceData();
|
||||
} catch (err: any) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.dismissError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setRejectingAccounts(false);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
/**
|
||||
* Handles confirming a pending deal.
|
||||
*/
|
||||
const handleConfirmDeal = useCallback(async (opportunityId: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmingDealId(opportunityId);
|
||||
|
||||
try {
|
||||
const result = await confirmPendingDeal(salesforceUrl, jwt, sessionId, opportunityId);
|
||||
|
||||
if (result.success) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.confirmDealSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
await refreshSalesforceData();
|
||||
}
|
||||
} catch (err: any) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.confirmDealError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setConfirmingDealId(null);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
/**
|
||||
* Handles rejecting all pending deals.
|
||||
*/
|
||||
const handleRejectAllDeals = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRejectingDeals(true);
|
||||
|
||||
try {
|
||||
await rejectPendingDeals(salesforceUrl, jwt, sessionId);
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.dismissSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
await refreshSalesforceData();
|
||||
} catch (err: any) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.dismissError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setRejectingDeals(false);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
/**
|
||||
* Handles linking a record from search results.
|
||||
*/
|
||||
const handleLinkRecord = useCallback(async (
|
||||
type: SalesforceObjectType,
|
||||
data: IAccountMatch | ILeadMatch | IContactMatch | IOpportunityMatch
|
||||
) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = type === 'Account' ? (data as IAccountMatch).accountId
|
||||
: type === 'Lead' ? (data as ILeadMatch).leadId
|
||||
: type === 'Contact' ? (data as IContactMatch).contactId
|
||||
: (data as IOpportunityMatch).opportunityId;
|
||||
|
||||
setLinkingId(id);
|
||||
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceProgress',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
|
||||
try {
|
||||
await linkSession(salesforceUrl, jwt, sessionId, type, data);
|
||||
|
||||
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
// Clear search and refresh data
|
||||
setSearchTerm('');
|
||||
setSearchResults(null);
|
||||
await refreshSalesforceData();
|
||||
} catch (err: any) {
|
||||
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setLinkingId(null);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
/**
|
||||
* Handles unlinking a record from the session.
|
||||
*/
|
||||
const handleUnlinkRecord = useCallback(async (type: SalesforceObjectType, id: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUnlinkingId(id);
|
||||
|
||||
try {
|
||||
await unlinkSession(salesforceUrl, jwt, sessionId, type, id);
|
||||
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.unlinkFromSalesforceSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
|
||||
await refreshSalesforceData();
|
||||
} catch (err: any) {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.unlinkFromSalesforceError',
|
||||
descriptionKey: err?.error,
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} finally {
|
||||
setUnlinkingId(null);
|
||||
}
|
||||
}, [ salesforceUrl, jwt, sessionId, dispatch, refreshSalesforceData ]);
|
||||
|
||||
// Filter out already-linked items from search results
|
||||
const filteredSearchResults = useMemo(() => {
|
||||
if (!searchResults || !salesforceData) {
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: searchResults.accounts.filter(
|
||||
account => salesforceData.account?.accountId !== account.accountId
|
||||
),
|
||||
leads: searchResults.leads.filter(
|
||||
lead => !salesforceData.leads?.some(l => l.leadId === lead.leadId)
|
||||
),
|
||||
contacts: searchResults.contacts.filter(
|
||||
contact => !salesforceData.contacts?.some(c => c.contactId === contact.contactId)
|
||||
),
|
||||
opportunities: searchResults.opportunities.filter(
|
||||
opp => salesforceData.deal?.opportunityId !== opp.opportunityId
|
||||
)
|
||||
};
|
||||
}, [ searchResults, salesforceData ]);
|
||||
|
||||
const hasSearchResults = filteredSearchResults
|
||||
&& (filteredSearchResults.accounts.length > 0
|
||||
|| filteredSearchResults.leads.length > 0
|
||||
|| filteredSearchResults.contacts.length > 0
|
||||
|| filteredSearchResults.opportunities.length > 0);
|
||||
|
||||
const hasPendingAccounts = (salesforceData?.pendingAccounts?.length ?? 0) > 0;
|
||||
const hasPendingDeals = (salesforceData?.pendingDeals?.length ?? 0) > 0;
|
||||
|
||||
return {
|
||||
// Data
|
||||
salesforceData,
|
||||
searchResults: filteredSearchResults,
|
||||
hasSearchResults,
|
||||
hasPendingAccounts,
|
||||
hasPendingDeals,
|
||||
|
||||
// State
|
||||
searchTerm,
|
||||
isLoading,
|
||||
isSearching,
|
||||
error,
|
||||
|
||||
// Action loading states
|
||||
confirmingAccountId,
|
||||
confirmingDealId,
|
||||
rejectingAccounts,
|
||||
rejectingDeals,
|
||||
linkingId,
|
||||
unlinkingId,
|
||||
|
||||
// Setters
|
||||
setSearchTerm,
|
||||
|
||||
// Actions
|
||||
handleConfirmAccount,
|
||||
handleRejectAllAccounts,
|
||||
handleConfirmDeal,
|
||||
handleRejectAllDeals,
|
||||
handleLinkRecord,
|
||||
handleUnlinkRecord,
|
||||
refreshSalesforceData
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user