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));
|
||||
} ],
|
||||
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';
|
||||
83
react/features/salesforce/components/native/RecordItem.tsx
Normal file
83
react/features/salesforce/components/native/RecordItem.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GestureResponderEvent, Text, TextStyle, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import { RECORD_TYPE } from '../../constants';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link RecordItem}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The id of the record.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The name of the record.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The handler for the click event.
|
||||
*/
|
||||
onClick?: (e?: GestureResponderEvent | React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* The type of the record.
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render Record data.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
export const RecordItem = ({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
|
||||
onClick = () => {}
|
||||
}: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const IconRecord = RECORD_TYPE[type ?? ''].icon;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress = { onClick }>
|
||||
<View
|
||||
key = { `record-${id}` }
|
||||
style = { styles.recordItem as ViewStyle }
|
||||
|
||||
// @ts-ignore
|
||||
title = { name }>
|
||||
<View style = { styles.recordTypeIcon as ViewStyle }>
|
||||
{IconRecord && (
|
||||
<Icon
|
||||
src = { IconRecord }
|
||||
style = { styles.recordIcon } />
|
||||
)}
|
||||
</View>
|
||||
<View style = { styles.recordDetails as ViewStyle }>
|
||||
<Text
|
||||
key = { name }
|
||||
numberOfLines = { 1 }
|
||||
style = { styles.recordName as TextStyle }>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
key = { type }
|
||||
style = { styles.recordType }>
|
||||
{t(RECORD_TYPE[type ?? ''].label)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Platform, SafeAreaView, ScrollView, Text, View, ViewStyle } from 'react-native';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IconSearch } from '../../../base/icons/svg';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
|
||||
import Button from '../../../base/ui/components/native/Button';
|
||||
import Input from '../../../base/ui/components/native/Input';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
|
||||
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../mobile/navigation/routes';
|
||||
import { CONTENT_HEIGHT_OFFSET, LIST_HEIGHT_OFFSET, NOTES_LINES, NOTES_MAX_LENGTH } from '../../constants';
|
||||
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
|
||||
|
||||
import { RecordItem } from './RecordItem';
|
||||
import styles from './styles';
|
||||
|
||||
/**
|
||||
* Component that renders the Salesforce link dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
const SalesforceLinkDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const { clientHeight } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
|
||||
const {
|
||||
hasDetailsErrors,
|
||||
hasRecordsErrors,
|
||||
isLoading,
|
||||
linkMeeting,
|
||||
notes,
|
||||
records,
|
||||
searchTerm,
|
||||
selectedRecord,
|
||||
selectedRecordOwner,
|
||||
setNotes,
|
||||
setSearchTerm,
|
||||
setSelectedRecord,
|
||||
showNoResults,
|
||||
showSearchResults
|
||||
} = useSalesforceLinkDialog();
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
navigate(screen.conference.main);
|
||||
selectedRecord && linkMeeting();
|
||||
}, [ navigate, linkMeeting ]);
|
||||
|
||||
const renderSpinner = () => (
|
||||
<View style = { [ styles.recordsSpinner, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] as ViewStyle[] }>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDetailsErrors = () => (
|
||||
<Text style = { styles.detailsError }>
|
||||
{t('dialog.searchResultsDetailsError')}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const renderSelection = () => (
|
||||
<SafeAreaView>
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
style = { [ styles.selectedRecord, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] as ViewStyle[] }>
|
||||
<View style = { styles.recordInfo as ViewStyle }>
|
||||
<RecordItem { ...selectedRecord } />
|
||||
{ selectedRecordOwner && <RecordItem { ...selectedRecordOwner } /> }
|
||||
{ hasDetailsErrors && renderDetailsErrors() }
|
||||
</View>
|
||||
<Text style = { styles.addNote }>
|
||||
{t('dialog.addOptionalNote')}
|
||||
</Text>
|
||||
<Input
|
||||
customStyles = {{ container: styles.notes }}
|
||||
maxLength = { NOTES_MAX_LENGTH }
|
||||
minHeight = { Platform.OS === 'ios' && NOTES_LINES ? 20 * NOTES_LINES : undefined }
|
||||
multiline = { true }
|
||||
numberOfLines = { Platform.OS === 'ios' ? undefined : NOTES_LINES }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onChange = { value => setNotes(value) }
|
||||
placeholder = { t('dialog.addMeetingNote') }
|
||||
value = { notes } />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
const renderRecordsSearch = () => (
|
||||
<View style = { styles.recordsSearchContainer as ViewStyle }>
|
||||
<Input
|
||||
icon = { IconSearch }
|
||||
maxLength = { NOTES_MAX_LENGTH }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onChange = { value => setSearchTerm(value) }
|
||||
placeholder = { t('dialog.searchInSalesforce') }
|
||||
value = { searchTerm ?? '' } />
|
||||
{(!isLoading && !hasRecordsErrors) && (
|
||||
<Text style = { styles.resultLabel }>
|
||||
{showSearchResults
|
||||
? t('dialog.searchResults', { count: records.length })
|
||||
: t('dialog.recentlyUsedObjects')
|
||||
}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderNoRecords = () => showNoResults && (
|
||||
<View style = { [ styles.noRecords, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] as ViewStyle[] }>
|
||||
<Text style = { styles.noRecordsText }>
|
||||
{t('dialog.searchResultsNotFound')}
|
||||
</Text>
|
||||
<Text style = { styles.noRecordsText }>
|
||||
{t('dialog.searchResultsTryAgain')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderRecordsError = () => (
|
||||
<View style = { [ styles.recordsError, { height: clientHeight - CONTENT_HEIGHT_OFFSET } ] as ViewStyle[] }>
|
||||
<Text style = { styles.recordsErrorText }>
|
||||
{t('dialog.searchResultsError')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return renderSpinner();
|
||||
}
|
||||
if (hasRecordsErrors) {
|
||||
return renderRecordsError();
|
||||
}
|
||||
if (showNoResults) {
|
||||
return renderNoRecords();
|
||||
}
|
||||
if (selectedRecord) {
|
||||
return renderSelection();
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<ScrollView
|
||||
bounces = { false }
|
||||
style = { [ styles.recordList, { height: clientHeight - LIST_HEIGHT_OFFSET } ] as ViewStyle[] }>
|
||||
{records.map((item: any) => (
|
||||
<RecordItem
|
||||
key = { `record-${item.id}` }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => setSelectedRecord(item) }
|
||||
{ ...item } />
|
||||
))}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.salesforceDialogContainer }>
|
||||
<View>
|
||||
{!selectedRecord && renderRecordsSearch()}
|
||||
{renderContent()}
|
||||
</View>
|
||||
{
|
||||
selectedRecord
|
||||
&& <View>
|
||||
<Button
|
||||
labelKey = 'dialog.Cancel'
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => setSelectedRecord(null) }
|
||||
style = { styles.cancelButton }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<Button
|
||||
labelKey = 'dialog.linkMeeting'
|
||||
onClick = { handlePress }
|
||||
style = { styles.linkButton }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</View>
|
||||
}
|
||||
</JitsiScreen>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesforceLinkDialog;
|
||||
148
react/features/salesforce/components/native/styles.ts
Normal file
148
react/features/salesforce/components/native/styles.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
export default {
|
||||
salesforceDialogContainer: {
|
||||
position: 'relative',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
backgroundColor: BaseTheme.palette.ui01
|
||||
},
|
||||
recordsSearchContainer: {
|
||||
alignSelf: 'stretch',
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
paddingHorizontal: BaseTheme.spacing[3],
|
||||
paddingTop: BaseTheme.spacing[2],
|
||||
position: 'relative'
|
||||
},
|
||||
searchIcon: {
|
||||
color: BaseTheme.palette.text03,
|
||||
fontSize: 30,
|
||||
left: 22,
|
||||
position: 'absolute',
|
||||
top: 22,
|
||||
zIndex: 2
|
||||
},
|
||||
resultLabel: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
color: BaseTheme.palette.text03,
|
||||
fontSize: 15,
|
||||
margin: 0,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 16
|
||||
},
|
||||
recordsSpinner: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%'
|
||||
},
|
||||
noRecords: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
padding: BaseTheme.spacing[3]
|
||||
},
|
||||
noRecordsText: {
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
recordsError: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 30
|
||||
},
|
||||
recordsErrorText: {
|
||||
color: BaseTheme.palette.text03
|
||||
},
|
||||
recordList: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
listStyle: 'none',
|
||||
paddingVertical: BaseTheme.spacing[3],
|
||||
position: 'relative'
|
||||
},
|
||||
selectedRecord: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
paddingTop: BaseTheme.spacing[3],
|
||||
position: 'relative'
|
||||
},
|
||||
recordInfo: {
|
||||
backgroundColor: BaseTheme.palette.ui03,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
display: 'flex',
|
||||
margin: BaseTheme.spacing[3],
|
||||
position: 'relative'
|
||||
},
|
||||
detailsError: {
|
||||
color: BaseTheme.palette.text03,
|
||||
padding: BaseTheme.spacing[3]
|
||||
},
|
||||
addNote: {
|
||||
color: BaseTheme.palette.text01,
|
||||
margin: BaseTheme.spacing[3]
|
||||
},
|
||||
notes: {
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: BaseTheme.palette.field01,
|
||||
borderColor: BaseTheme.palette.ui05,
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
borderWidth: 1,
|
||||
color: BaseTheme.palette.text01,
|
||||
lineHeight: 18,
|
||||
marginHorizontal: BaseTheme.spacing[3],
|
||||
marginVertical: BaseTheme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
padding: BaseTheme.spacing[2],
|
||||
textAlignVertical: 'top'
|
||||
},
|
||||
cancelButton: {
|
||||
margin: BaseTheme.spacing[2]
|
||||
},
|
||||
linkButton: {
|
||||
marginBottom: BaseTheme.spacing[2],
|
||||
marginHorizontal: BaseTheme.spacing[2]
|
||||
},
|
||||
recordItem: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
recordTypeIcon: {
|
||||
alignItems: 'center',
|
||||
borderRadius: BaseTheme.shape.borderRadius,
|
||||
display: 'flex',
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
marginRight: BaseTheme.spacing[3],
|
||||
width: 40
|
||||
},
|
||||
recordIcon: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
recordDetails: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-around',
|
||||
overflow: 'hidden',
|
||||
paddingVertical: BaseTheme.spacing[3]
|
||||
},
|
||||
recordName: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 15,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
recordType: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 13
|
||||
}
|
||||
};
|
||||
107
react/features/salesforce/components/web/RecordItem.tsx
Normal file
107
react/features/salesforce/components/web/RecordItem.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { RECORD_TYPE } from '../../constants';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link RecordItem}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The id of the record.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The name of the record.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The handler for the click event.
|
||||
*/
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* The type of the record.
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
recordItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
recordTypeIcon: {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: '40px',
|
||||
marginRight: '16px',
|
||||
width: '40px'
|
||||
},
|
||||
recordDetails: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-around',
|
||||
overflow: 'hidden',
|
||||
padding: '12px 0',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
recordName: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.25rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
recordType: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.125rem'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Component to render Record data.
|
||||
*
|
||||
* @param {IProps} props - The props of the component.
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
export const RecordItem = ({
|
||||
id,
|
||||
name,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onClick = () => {},
|
||||
type
|
||||
}: IProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = RECORD_TYPE[type as keyof typeof RECORD_TYPE].icon;
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<li
|
||||
className = { classes.recordItem }
|
||||
key = { `record-${id}` }
|
||||
onClick = { onClick }
|
||||
title = { name }>
|
||||
<div className = { classes.recordTypeIcon }>{Icon && <Icon />}</div>
|
||||
<div className = { classes.recordDetails }>
|
||||
<div
|
||||
className = { classes.recordName }
|
||||
key = { name }>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className = { classes.recordType }
|
||||
key = { type }>
|
||||
{t(RECORD_TYPE[type ?? ''].label)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
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 { NOTES_MAX_LENGTH } from '../../constants';
|
||||
import { useSalesforceLinkDialog } from '../../useSalesforceLinkDialog';
|
||||
|
||||
import { RecordItem } from './RecordItem';
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
height: '450px',
|
||||
overflowY: 'auto',
|
||||
position: 'relative'
|
||||
},
|
||||
recordsSearchContainer: {
|
||||
position: 'relative',
|
||||
padding: '1px'
|
||||
},
|
||||
searchIcon: {
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
color: theme.palette.text03,
|
||||
left: 16,
|
||||
top: 10,
|
||||
width: 20,
|
||||
height: 20
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: '1rem',
|
||||
margin: '16px 0 8px'
|
||||
},
|
||||
recordsSearch: {
|
||||
backgroundColor: theme.palette.field01,
|
||||
border: '1px solid',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.ui05,
|
||||
color: theme.palette.text01,
|
||||
padding: '10px 16px 10px 44px',
|
||||
width: '100%',
|
||||
height: 40,
|
||||
'&::placeholder': {
|
||||
color: theme.palette.text03,
|
||||
...theme.typography.bodyShortRegular
|
||||
}
|
||||
},
|
||||
spinner: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
height: 'calc(100% - 70px)',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
height: 'auto',
|
||||
marginTop: '24px'
|
||||
}
|
||||
},
|
||||
noRecords: {
|
||||
height: 'calc(100% - 150px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
height: 'auto',
|
||||
marginTop: '24px'
|
||||
}
|
||||
},
|
||||
recordsError: {
|
||||
height: 'calc(100% - 42px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
|
||||
'@media (max-width: 448px)': {
|
||||
height: 'auto',
|
||||
marginTop: '24px'
|
||||
}
|
||||
},
|
||||
recordList: {
|
||||
listStyle: 'none',
|
||||
margin: '10px 0',
|
||||
padding: 0
|
||||
},
|
||||
recordInfo: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
padding: '0 16px',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
marginBottom: '28px'
|
||||
},
|
||||
detailsError: {
|
||||
padding: '10px 0'
|
||||
},
|
||||
addNote: {
|
||||
padding: '10px 0'
|
||||
},
|
||||
notes: {
|
||||
lineHeight: '1.125rem',
|
||||
minHeight: '130px',
|
||||
resize: 'vertical',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.ui05,
|
||||
backgroundColor: theme.palette.field01,
|
||||
color: theme.palette.text01,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: '10px 16px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Component that renders the Salesforce link dialog.
|
||||
*
|
||||
* @returns {React$Element<any>}
|
||||
*/
|
||||
function SalesforceLinkDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { classes, theme } = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
hasDetailsErrors,
|
||||
hasRecordsErrors,
|
||||
isLoading,
|
||||
linkMeeting,
|
||||
notes,
|
||||
records,
|
||||
searchTerm,
|
||||
selectedRecord,
|
||||
selectedRecordOwner,
|
||||
setNotes,
|
||||
setSearchTerm,
|
||||
setSelectedRecord,
|
||||
showNoResults,
|
||||
showSearchResults
|
||||
} = useSalesforceLinkDialog();
|
||||
|
||||
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = getFieldValue(event);
|
||||
|
||||
setSearchTerm(value);
|
||||
}, [ getFieldValue ]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
dispatch(hideDialog());
|
||||
selectedRecord && linkMeeting();
|
||||
}, [ hideDialog, linkMeeting ]);
|
||||
|
||||
const renderSpinner = () => (
|
||||
<div className = { classes.spinner }>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsErrors = () => (
|
||||
<div className = { classes.detailsError }>
|
||||
{t('dialog.searchResultsDetailsError')}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelection = () => (
|
||||
<div>
|
||||
<div className = { classes.recordInfo }>
|
||||
<RecordItem { ...selectedRecord } />
|
||||
{selectedRecordOwner && <RecordItem { ...selectedRecordOwner } />}
|
||||
{hasDetailsErrors && renderDetailsErrors()}
|
||||
</div>
|
||||
<div className = { classes.addNote }>{t('dialog.addOptionalNote')}</div>
|
||||
<textarea
|
||||
autoFocus = { true }
|
||||
className = { classes.notes }
|
||||
maxLength = { NOTES_MAX_LENGTH }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onChange = { e => setNotes(e.target.value) }
|
||||
placeholder = { t('dialog.addMeetingNote') }
|
||||
rows = { 4 }
|
||||
value = { notes } />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRecordsSearch = () => !selectedRecord && (
|
||||
<div className = { classes.recordsSearchContainer }>
|
||||
<Icon
|
||||
className = { classes.searchIcon }
|
||||
color = { theme.palette.icon03 }
|
||||
src = { IconSearch } />
|
||||
<input
|
||||
autoComplete = 'off'
|
||||
autoFocus = { false }
|
||||
className = { classes.recordsSearch }
|
||||
name = 'recordsSearch'
|
||||
onChange = { handleChange }
|
||||
placeholder = { t('dialog.searchInSalesforce') }
|
||||
tabIndex = { 0 }
|
||||
value = { searchTerm ?? '' } />
|
||||
{(!isLoading && !hasRecordsErrors) && (
|
||||
<div className = { classes.resultLabel }>
|
||||
{showSearchResults
|
||||
? t('dialog.searchResults', { count: records.length })
|
||||
: t('dialog.recentlyUsedObjects')
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderNoRecords = () => showNoResults && (
|
||||
<div className = { classes.noRecords }>
|
||||
<div>{t('dialog.searchResultsNotFound')}</div>
|
||||
<div>{t('dialog.searchResultsTryAgain')}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRecordsError = () => (
|
||||
<div className = { classes.recordsError }>
|
||||
{t('dialog.searchResultsError')}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return renderSpinner();
|
||||
}
|
||||
if (hasRecordsErrors) {
|
||||
return renderRecordsError();
|
||||
}
|
||||
if (showNoResults) {
|
||||
return renderNoRecords();
|
||||
}
|
||||
if (selectedRecord) {
|
||||
return renderSelection();
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className = { classes.recordList }>
|
||||
{records.map((item: any) => (
|
||||
<RecordItem
|
||||
key = { `record-${item.id}` }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onClick = { () => setSelectedRecord(item) }
|
||||
{ ...item } />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
back = {{
|
||||
hidden: !selectedRecord,
|
||||
onClick: () => setSelectedRecord(null),
|
||||
translationKey: 'dialog.Back'
|
||||
}}
|
||||
cancel = {{ hidden: true }}
|
||||
disableEnter = { true }
|
||||
ok = {{
|
||||
translationKey: 'dialog.linkMeeting',
|
||||
hidden: !selectedRecord
|
||||
}}
|
||||
onSubmit = { handleSubmit }
|
||||
titleKey = 'dialog.linkMeetingTitle'>
|
||||
<div className = { classes.container } >
|
||||
{renderRecordsSearch()}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SalesforceLinkDialog;
|
||||
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'
|
||||
}
|
||||
};
|
||||
114
react/features/salesforce/functions.ts
Normal file
114
react/features/salesforce/functions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { doGetJSON } from '../base/util/httpUtils';
|
||||
import { isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the Salesforce records that were most recently interacted with.
|
||||
*
|
||||
* @param {string} url - The endpoint for the session records.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function getRecentSessionRecords(
|
||||
url: string,
|
||||
jwt: string
|
||||
) {
|
||||
return doGetJSON(`${url}/records/recents`, true, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Salesforce records that match the search criteria.
|
||||
*
|
||||
* @param {string} url - The endpoint for the session records.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} text - The search term for the session record to find.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function searchSessionRecords(
|
||||
url: string,
|
||||
jwt: string,
|
||||
text: string
|
||||
) {
|
||||
return doGetJSON(`${url}/records?text=${text}`, true, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Salesforce record details from the server.
|
||||
*
|
||||
* @param {string} url - The endpoint for the record details.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {Object} item - The item for which details are being retrieved.
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function getSessionRecordDetails(
|
||||
url: string,
|
||||
jwt: string,
|
||||
item: {
|
||||
id: string;
|
||||
type: string;
|
||||
} | null
|
||||
) {
|
||||
const fullUrl = `${url}/records/${item?.id}?type=${item?.type}`;
|
||||
|
||||
return doGetJSON(fullUrl, true, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the meeting linking.
|
||||
*
|
||||
* @param {string} url - The endpoint for meeting linking.
|
||||
* @param {string} jwt - The JWT needed for authentication.
|
||||
* @param {string} sessionId - The ID of the meeting session.
|
||||
* @param {Object} body - The body of the request.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export async function executeLinkMeetingRequest(
|
||||
url: string,
|
||||
jwt: string,
|
||||
sessionId: String,
|
||||
body: {
|
||||
id?: string;
|
||||
notes: string;
|
||||
type?: string;
|
||||
}
|
||||
) {
|
||||
const fullUrl = `${url}/sessions/${sessionId}/records/${body.id}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return res.ok ? json : Promise.reject(json);
|
||||
}
|
||||
156
react/features/salesforce/useSalesforceLinkDialog.ts
Normal file
156
react/features/salesforce/useSalesforceLinkDialog.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GestureResponderEvent } from 'react-native';
|
||||
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 {
|
||||
executeLinkMeetingRequest,
|
||||
getRecentSessionRecords,
|
||||
getSessionRecordDetails,
|
||||
searchSessionRecords
|
||||
} from './functions';
|
||||
|
||||
interface ISelectedRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
onClick: (e?: React.MouseEvent | GestureResponderEvent) => void;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export const useSalesforceLinkDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [ selectedRecord, setSelectedRecord ] = useState<ISelectedRecord | null>(null);
|
||||
const [ selectedRecordOwner, setSelectedRecordOwner ] = useState<{
|
||||
id: string; name: string; type: string; } | null>(null);
|
||||
const [ records, setRecords ] = useState([]);
|
||||
const [ isLoading, setLoading ] = useState(false);
|
||||
const [ searchTerm, setSearchTerm ] = useState<string | null>(null);
|
||||
const [ notes, setNotes ] = useState('');
|
||||
const [ hasRecordsErrors, setRecordsErrors ] = useState(false);
|
||||
const [ hasDetailsErrors, setDetailsErrors ] = useState(false);
|
||||
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']);
|
||||
const showSearchResults = searchTerm && searchTerm.length > 1;
|
||||
const showNoResults = showSearchResults && records.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecords = async () => {
|
||||
setRecordsErrors(false);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const text = showSearchResults ? searchTerm : null;
|
||||
const result = text
|
||||
? await searchSessionRecords(salesforceUrl, jwt, text)
|
||||
: await getRecentSessionRecords(salesforceUrl, jwt);
|
||||
|
||||
setRecords(result);
|
||||
} catch (error) {
|
||||
setRecordsErrors(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchRecords();
|
||||
}, [
|
||||
getRecentSessionRecords,
|
||||
jwt,
|
||||
salesforceUrl,
|
||||
searchSessionRecords,
|
||||
searchTerm
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecordDetails = async () => {
|
||||
setDetailsErrors(false);
|
||||
setSelectedRecordOwner(null);
|
||||
try {
|
||||
const result = await getSessionRecordDetails(salesforceUrl, jwt, selectedRecord);
|
||||
|
||||
setSelectedRecordOwner({
|
||||
id: result.id,
|
||||
name: result.ownerName,
|
||||
type: 'OWNER'
|
||||
});
|
||||
} catch (error) {
|
||||
setDetailsErrors(true);
|
||||
}
|
||||
};
|
||||
|
||||
selectedRecord && fetchRecordDetails();
|
||||
}, [
|
||||
jwt,
|
||||
getSessionRecordDetails,
|
||||
salesforceUrl,
|
||||
selectedRecord
|
||||
]);
|
||||
|
||||
const linkMeeting = useCallback(async () => {
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceProgress',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
|
||||
try {
|
||||
await executeLinkMeetingRequest(salesforceUrl, jwt, sessionId, {
|
||||
id: selectedRecord?.id,
|
||||
type: selectedRecord?.type,
|
||||
notes
|
||||
});
|
||||
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceSuccess',
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.SUCCESS
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
} catch (error: any) {
|
||||
dispatch(hideNotification(SALESFORCE_LINK_NOTIFICATION_ID));
|
||||
dispatch(showNotification({
|
||||
titleKey: 'notify.linkToSalesforceError',
|
||||
descriptionKey: error?.messageKey && t(error.messageKey),
|
||||
uid: SALESFORCE_LINK_NOTIFICATION_ID,
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
}
|
||||
|
||||
}, [
|
||||
executeLinkMeetingRequest,
|
||||
hideNotification,
|
||||
jwt,
|
||||
notes,
|
||||
salesforceUrl,
|
||||
selectedRecord,
|
||||
showNotification
|
||||
]);
|
||||
|
||||
return {
|
||||
hasDetailsErrors,
|
||||
hasRecordsErrors,
|
||||
isLoading,
|
||||
linkMeeting,
|
||||
notes,
|
||||
records,
|
||||
searchTerm,
|
||||
selectedRecord,
|
||||
selectedRecordOwner,
|
||||
setNotes,
|
||||
setSearchTerm,
|
||||
setSelectedRecord,
|
||||
showNoResults,
|
||||
showSearchResults
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user