ToolJet/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx
Devanshu Gupta 6f572315c1
feat: added hubspot (#13854)
* feat: added hubspot

* fix: updated name

* feat: updated spec url

* fix: added options in manifest

* fix: resolved comments

* fix: added kind

* fix: updated query options check

* fix: kind index
2025-08-21 18:54:05 +05:30

1509 lines
55 KiB
JavaScript

import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { datasourceService, pluginsService, globalDatasourceService, libraryAppService } from '@/_services';
import cx from 'classnames';
import { Modal, Button, Tab, Row, Col, ListGroup, ModalBody } from 'react-bootstrap';
import { toast } from 'react-hot-toast';
import { getSvgIcon } from '@/_helpers/appUtils';
import { TestConnection } from './TestConnection';
import { getWorkspaceId, deepEqual, returnDevelopmentEnv, decodeEntities } from '@/_helpers/utils';
import { getSubpath } from '@/_helpers/routes';
import {
DataBaseSources,
ApiSources,
DataSourceTypes,
SourceComponent,
SourceComponents,
CloudStorageSources,
} from '../../../common/components/DataSourceComponents';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import config from 'config';
import { capitalize, isEmpty } from 'lodash';
import { Card } from '@/_ui/Card';
import { withTranslation, useTranslation } from 'react-i18next';
import { camelizeKeys, decamelizeKeys, decamelize } from 'humps';
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { useAppVersionStore } from '@/_stores/appVersionStore';
import { ConfirmDialog, ToolTip } from '@/_components';
import { shallow } from 'zustand/shallow';
import { useDataSourcesStore } from '@/_stores/dataSourcesStore';
import { withRouter } from '@/_hoc/withRouter';
import useGlobalDatasourceUnsavedChanges from '@/_hooks/useGlobalDatasourceUnsavedChanges';
import { LicenseTooltip } from '@/LicenseTooltip';
import { DATA_SOURCE_TYPE } from '@/_helpers/constants';
import './dataSourceManager.theme.scss';
import { canUpdateDataSource } from '@/_helpers';
import DataSourceSchemaManager from '@/_helpers/dataSourceSchemaManager';
import MultiEnvTabs from './MultiEnvTabs';
import { generateCypressDataCy } from '../../../common/helpers/cypressHelpers';
import posthogHelper from '@/modules/common/helpers/posthogHelper';
class DataSourceManagerComponent extends React.Component {
constructor(props) {
super(props);
let selectedDataSource = null;
let dataSourceSchema = null;
let selectedDataSourceIcon = null;
let options = {};
let dataSourceMeta = {};
let datasourceName = '';
if (props.selectedDataSource) {
selectedDataSource = props.selectedDataSource;
options = selectedDataSource.options;
dataSourceMeta = this.getDataSourceMeta(selectedDataSource);
dataSourceSchema = props.selectedDataSource?.plugin?.manifestFile?.data;
selectedDataSourceIcon = props.selectDataSource?.plugin?.iconFile.data;
datasourceName = props.selectedDataSource?.name;
}
this.state = {
showModal: true,
appId: props.appId,
selectedDataSource,
dataSourceSchema,
selectedDataSourceIcon,
options,
dataSourceMeta,
isSaving: false,
isCopied: false,
queryString: null,
plugins: [],
filteredDatasources: [],
activeDatasourceList: '#alldatasources',
suggestingDatasources: false,
scope: props?.scope,
modalProps: props?.modalProps ?? {},
showBackButton: props?.showBackButton ?? true,
defaultOptions: {},
pluginsLoaded: false,
dataSourceConfirmModalProps: { isOpen: false, dataSource: null, category: null },
addingDataSource: false,
createdDataSource: null,
unsavedChangesModal: false,
datasourceName,
creatingApp: false,
validationError: [],
validationMessages: {},
showValidationErrors: false,
};
}
componentDidMount() {
this.setState({
appId: this.props.appId,
});
pluginsService
.findAll()
.then(({ data = [] }) => {
this.setState({ plugins: data, pluginsLoaded: true });
})
.catch((error) => {
this.setState({ pluginsLoaded: true });
toast.error(error?.message || 'failed to fetch plugins');
});
}
componentDidUpdate(prevProps) {
this.props.setGlobalDataSourceStatus({ saveAction: this.createDataSource });
if (prevProps.selectedDataSource !== this.props.selectedDataSource) {
let dataSourceMeta = this.getDataSourceMeta(this.props.selectedDataSource);
this.setState({
selectedDataSource: this.props.selectedDataSource,
options: this.props.selectedDataSource?.options,
dataSourceMeta,
dataSourceSchema: this.props.selectedDataSource?.plugin?.manifestFile?.data,
selectedDataSourceIcon: this.props.selectedDataSource?.plugin?.iconFile?.data,
connectionTestError: null,
datasourceName: this.props.selectedDataSource?.name,
validationMessages: {},
validationError: [],
showValidationErrors: false,
});
}
}
getDataSourceMeta = (dataSource) => {
if (!dataSource) return {};
if (dataSource?.pluginId) {
let dataSourceMeta = camelizeKeys(dataSource?.plugin?.manifestFile?.data.source);
dataSourceMeta.options = decamelizeKeys(dataSourceMeta.options);
return dataSourceMeta;
}
return DataSourceTypes.find((source) => source?.kind === dataSource?.kind);
};
selectDataSource = (source, category) => {
posthogHelper.captureEvent('choose_datasource', {
dataSource: source?.kind,
category,
appId: this.state.appId,
});
this.hideModal();
this.setState(
{
dataSourceMeta: source.manifestFile?.data?.source ?? source,
selectedDataSource: source.manifestFile?.data?.source ?? source,
options: source?.defaults ?? source?.options,
selectedDataSourceIcon: source.iconFile?.data,
name: source.manifestFile?.data?.source?.kind ?? source.kind,
dataSourceSchema: source.manifestFile?.data,
selectedDataSourcePluginId: source.id,
datasourceName: source.name,
validationMessages: {},
validationError: [],
showValidationErrors: false,
},
() => this.createDataSource()
);
};
onNameChanged = (newName) => {
this.setState({
selectedDataSource: {
...this.state.selectedDataSource,
name: newName,
},
});
};
onExit = () => {
!this.state.selectedDataSource?.id && this.props.environmentChanged(returnDevelopmentEnv(this.props.environments));
this.setState({
dataSourceMeta: {},
selectedDataSource: null,
options: {},
connectionTestError: null,
queryString: null,
filteredDatasources: [],
activeDatasourceList: '#alldatasources',
});
};
setStateAsync = (state) => {
return new Promise((resolve) => {
this.setState(state, resolve);
});
};
optionchanged = (option, value) => {
const stateToUpdate = {
connectionTestError: null,
options: {
...this.state.options,
[option]: { value: value },
},
};
return this.setStateAsync(stateToUpdate);
};
resetOptions = () => {
return this.setStateAsync({
connectionTestError: null,
options: this.state.defaultOptions,
});
};
hideModal = (ds = null) => {
this.onExit();
this.props.hideModal(ds);
};
resetDataSourceConfirmModal = () => {
this.setState({
dataSourceConfirmModalProps: {
isOpen: false,
dataSource: null,
category: null,
},
});
};
createDataSource = () => {
const {
appId,
options,
selectedDataSource,
selectedDataSourcePluginId,
dataSourceMeta,
dataSourceSchema,
validationMessages,
validationError,
} = this.state;
if (!isEmpty(validationMessages)) {
const validationMessageArray = Object.values(validationMessages);
this.setState({ validationError: validationMessageArray, showValidationErrors: true });
toast.error(
this.props.t(
'editor.queryManager.dataSourceManager.toast.error.validationFailed',
'Validation failed. Please check your inputs.'
),
{ position: 'top-center' }
);
if (validationMessageArray.length > 0) {
return false;
}
}
const OAuthDs = ['slack', 'zendesk', 'googlesheets', 'salesforce', 'googlecalendar', 'microsoft_graph', 'hubspot'];
const name = selectedDataSource.name;
const kind = selectedDataSource?.kind;
const pluginId = selectedDataSourcePluginId;
const appVersionId = useAppVersionStore?.getState()?.editingVersion?.id;
const currentAppEnvironmentId = this.props.currentAppEnvironmentId ?? this.props.currentEnvironment?.id;
const scope = this.state?.scope || selectedDataSource?.scope;
posthogHelper.captureEvent('save_connection_datasource', { dataSource: kind, appId }); //posthog event
const parsedOptions = Object?.keys(options)?.map((key) => {
let keyMeta = dataSourceMeta.options[key];
let isEncrypted = false;
if (keyMeta) {
isEncrypted = keyMeta.encrypted;
}
// to resolve any casing mis-match
if (decamelize(key) !== key) {
const newKey = decamelize(key);
isEncrypted = dataSourceMeta.options[newKey]?.encrypted;
}
return {
key: key,
value: options[key].value,
encrypted: isEncrypted,
...(!options[key]?.value && { credential_id: options[key]?.credential_id }),
};
});
if (OAuthDs.includes(kind)) {
const value = localStorage.getItem('OAuthCode');
parsedOptions.push({ key: 'code', value, encrypted: false });
}
if (name.trim() !== '') {
let service = scope === 'global' ? globalDatasourceService : datasourceService;
if (selectedDataSource.id) {
this.setState({ isSaving: true });
this.props.setGlobalDataSourceStatus({ isSaving: true, isEditing: false });
service
.save({
id: selectedDataSource.id,
name,
options: parsedOptions,
app_id: appId,
environment_id: currentAppEnvironmentId,
})
.then(() => {
this.props.updateSelectedDatasource && this.props.updateSelectedDatasource(selectedDataSource.name);
this.setState({ isSaving: false });
this.hideModal(selectedDataSource);
toast.success(
this.props.t('editor.queryManager.dataSourceManager.toast.success.dataSourceSaved', 'Data Source Saved'),
{ position: 'top-center' }
);
this.props.dataSourcesChanged(false, selectedDataSource);
this.props.globalDataSourcesChanged && this.props.globalDataSourcesChanged();
this.props.setGlobalDataSourceStatus({ isSaving: false, isEditing: false });
scope === 'local' && this.hideModal();
})
.catch(({ error }) => {
this.setState({ isSaving: false });
this.hideModal(selectedDataSource);
error && toast.error(error, { position: 'top-center' });
this.resetDataSourceConfirmModal();
this.props.setGlobalDataSourceStatus({ isSaving: false, isEditing: false });
});
} else {
this.setState({ isSaving: true, addingDataSource: true });
service
.create({
plugin_id: pluginId,
name,
kind,
options: parsedOptions,
app_id: appId,
app_version_id: appVersionId,
scope,
environment_id: currentAppEnvironmentId,
})
.then((data) => {
this.setState({ isSaving: false, addingDataSource: false });
this.props.updateSelectedDatasource && this.props.updateSelectedDatasource(name);
this.hideModal(selectedDataSource);
toast.success(
this.props.t('editor.queryManager.dataSourceManager.toast.success.dataSourceAdded', 'Data Source Added'),
{ position: 'top-center' }
);
this.props.dataSourcesChanged(false, data);
this.props.globalDataSourcesChanged && this.props.globalDataSourcesChanged();
this.resetDataSourceConfirmModal();
})
.catch(({ error }) => {
this.setState({ isSaving: false, addingDataSource: false });
this.hideModal();
error && toast.error(error, { position: 'top-center' });
this.resetDataSourceConfirmModal();
});
}
} else {
toast.error(
this.props.t(
'editor.queryManager.dataSourceManager.toast.error.noEmptyDsName',
'The name of datasource should not be empty'
),
{ position: 'top-center' }
);
}
};
handleSearch = (searchQuery, activeDatasourceList) => {
this.setState({ queryString: searchQuery });
const arr = [];
const filteredDatasources = this.datasourcesGroups().filter((group) => group.key === activeDatasourceList)[0].list;
filteredDatasources.forEach((datasource) => {
if (datasource.name.toLowerCase().includes(searchQuery.toLowerCase())) {
arr.push(datasource);
}
});
this.setState({ filteredDatasources: arr });
};
handleBackToAllDatasources = () => {
this.setState({
queryString: null,
filteredDatasources: [],
activeDatasourceList: '#alldatasources',
});
};
updateSuggestedDatasources = () => {
this.setState({ suggestingDatasources: true, activeDatasourceList: '#' });
};
setValidationMessages = (errors, schema, interactedFields) => {
const errorMap = errors.reduce((acc, error) => {
// Get property name from either required error or dataPath
const property =
error.keyword === 'required'
? error.params.missingProperty
: error.dataPath?.replace(/^[./]/, '') || error.instancePath?.replace(/^[./]/, '');
if (property) {
const propertySchema = schema.properties?.[property];
const propertyTitle = propertySchema?.title;
acc[property] =
error.keyword === 'required' ? `${propertyTitle} is required` : `${propertyTitle} ${error.message}`;
}
return acc;
}, {});
this.setState({ validationMessages: errorMap });
const filteredValidationBanner = interactedFields
? Object.keys(this.state.validationMessages)
.filter((key) => interactedFields.has(key))
.reduce((result, key) => {
result.push(this.state.validationMessages[key]);
return result;
}, [])
: Object.values(this.state.validationMessages);
this.setState({ validationError: filteredValidationBanner });
};
renderSourceComponent = (kind, isPlugin = false) => {
const { options, isSaving, showValidationErrors } = this.state;
const sourceComponentName = kind?.charAt(0).toUpperCase() + kind?.slice(1);
const ComponentToRender = isPlugin ? SourceComponent : SourceComponents[sourceComponentName] || SourceComponent;
return (
<ComponentToRender
key={this.state.selectedDataSource?.id}
dataSourceSchema={this.state.dataSourceSchema}
optionsChanged={(options = {}) => this.setState({ options })}
optionchanged={this.optionchanged}
createDataSource={this.createDataSource}
options={options}
isSaving={isSaving}
hideModal={this.hideModal}
selectedDataSource={this.state.selectedDataSource}
isEditMode={!isEmpty(this.state.selectedDataSource)}
currentAppEnvironmentId={this.props.currentEnvironment?.id}
validationMessages={this.state.validationMessages}
setValidationMessages={this.setValidationMessages}
clearValidationMessages={() => this.setState({ validationMessages: {} })}
setDefaultOptions={this.setDefaultOptions}
showValidationErrors={showValidationErrors}
clearValidationErrorBanner={() => this.setState({ validationError: [] })}
/>
);
};
setDefaultOptions = (defaults) => {
this.setState({
defaultOptions: defaults,
});
};
onConnectionTestFailed = (data) => {
this.setState({ connectionTestError: data });
};
segregateDataSources = (suggestingDatasources, darkMode) => {
const datasources = this.datasourcesGroups();
const handleOnSelect = (activekey) => {
if (suggestingDatasources) {
this.setState({ suggestingDatasources: false });
}
this.setState({ activeDatasourceList: activekey });
};
const goBacktoAllDatasources = () => {
this.setState({ suggestingDatasources: false });
this.handleBackToAllDatasources();
};
const datasourceSuggestionUI = () => {
return (
<div className="empty-state-wrapper suggestingDatasourcesWrapper">
<EmptyStateContainer
suggestionUI={true}
queryString={this.state.queryString}
handleBackToAllDatasources={goBacktoAllDatasources}
darkMode={this.props.darkMode}
placeholder={this.props.t(
'editor.queryManager.dataSourceManager.suggestAnIntegration',
'Suggest an integration'
)}
/>
</div>
);
};
return (
<Tab.Container
activeKey={this.state.activeDatasourceList}
unmountOnExit={true}
onSelect={(activekey) => handleOnSelect(activekey)}
id="list-group-tabs-example"
defaultActiveKey={this.state.activeDatasourceList}
>
<Row>
<Col sm={6} md={4} className={`modal-sidebar ${darkMode ? 'dark' : ''}`}>
{this.renderSidebarList()}
</Col>
<Col style={{ left: '25%' }} className={`modal-body-content ${darkMode ? 'dark' : ''}`}>
<div className="selected-datasource-list-content">
<Tab.Content>
{suggestingDatasources ? (
<div className="suggestion-container">
<h4 className="justify-content-start">
{this.props.t('editor.queryManager.dataSourceManager.suggestDataSource', 'Suggest Datasource')}
</h4>
{datasourceSuggestionUI()}
</div>
) : (
<>
<div className="input-icon modal-searchbar">
<SearchBoxContainer
dataCy={'datasource-search-input'}
onChange={this.handleSearch}
onClear={this.handleBackToAllDatasources}
queryString={this.state.queryString}
activeDatasourceList={this.state.activeDatasourceList}
scope={this.state.scope}
className="tj-text"
/>
</div>
{datasources.map((datasource) => (
<Tab.Pane
transition={false}
active={this.state.activeDatasourceList === datasource.key}
bsPrefix={`datasource-modal-${this.state.activeDatasourceList}`}
eventKey={datasource.key}
key={datasource.key}
>
{datasource.renderDatasources()}
</Tab.Pane>
))}
</>
)}
{!suggestingDatasources && this.state.queryString && this.state.filteredDatasources.length === 0 && (
<div className="empty-state-wrapper row">
<EmptyStateContainer
queryString={this.state.queryString}
handleBackToAllDatasources={this.handleBackToAllDatasources}
darkMode={this.props.darkMode}
placeholder={this.props.t(
'editor.queryManager.dataSourceManager.whatLookingFor',
'Tell us what you were looking for?'
)}
/>
</div>
)}
</Tab.Content>
</div>
</Col>
</Row>
</Tab.Container>
);
};
datasourcesGroups = () => {
const allDataSourcesList = {
databases: DataBaseSources,
apis: ApiSources,
cloudStorages: CloudStorageSources,
plugins: this.state.plugins,
filteredDatasources: this.state.filteredDatasources,
};
const dataSourceList = [
{
type: 'All Datasources',
key: '#alldatasources',
list: [
...allDataSourcesList.databases,
...allDataSourcesList.apis,
...allDataSourcesList.cloudStorages,
...allDataSourcesList.plugins,
],
renderDatasources: () => this.renderCardGroup(allDataSourcesList, 'All Datasources'),
},
{
type: 'Databases',
key: '#databases',
list: allDataSourcesList.databases,
renderDatasources: () => this.renderCardGroup(allDataSourcesList.databases, 'Databases'),
},
{
type: 'APIs',
key: '#apis',
list: allDataSourcesList.apis,
renderDatasources: () => this.renderCardGroup(allDataSourcesList.apis, 'APIs'),
},
{
type: 'Cloud Storage',
key: '#cloudstorage',
list: allDataSourcesList.cloudStorages,
renderDatasources: () => this.renderCardGroup(allDataSourcesList.cloudStorages, 'Cloud Storages'),
},
{
type: 'Plugins',
key: '#plugins',
list: allDataSourcesList.plugins,
renderDatasources: () => this.renderCardGroup(allDataSourcesList.plugins, 'Plugins'),
},
{
type: 'Filtered Datasources',
key: '#filtereddatasources',
list: allDataSourcesList.filteredDatasources,
renderDatasources: () => this.renderCardGroup(this.state.filteredDatasources, this.state.activeDatasourceList),
},
];
return dataSourceList;
};
renderSidebarList = () => {
const dataSourceList = this.datasourcesGroups().splice(0, 5);
const updateSuggestionState = () => {
this.updateSuggestedDatasources();
};
return (
<>
<ListGroup className="datasource-lists-modal" variant="flush">
{dataSourceList.map((datasource) => (
<ListGroup.Item key={datasource.key} eventKey={datasource.key}>
{`${datasource.type} (${datasource.list.length})`}
</ListGroup.Item>
))}
</ListGroup>
<div className="datasource-modal-sidebar-footer">
<p>
<span className="footer-text">
{this.props.t(
'editor.queryManager.dataSourceManager.noResultFound',
`Don't see what you were looking for?`
)}
</span>
<br />
<span className="link-span" onClick={updateSuggestionState}>
{this.props.t('editor.queryManager.dataSourceManager.suggest', 'Suggest')}
</span>
</p>
</div>
</>
);
};
createSampleApp = () => {
let _self = this;
_self.setState({ creatingApp: true });
libraryAppService
.createSampleApp()
.then((data) => {
const workspaceId = getWorkspaceId();
const subpath = getSubpath();
const path = subpath
? `${subpath}/${workspaceId}/apps/${data.app[0].id}`
: `/${workspaceId}/apps/${data.app[0].id}`;
window.open(path, '_blank');
toast.success('App created successfully!');
_self.setState({ creatingApp: false });
})
.catch((errorResponse) => {
_self.setState({ creatingApp: false });
const message = errorResponse?.error;
toast.error(message);
});
};
renderSampleDBModal = () => {
const { dataSourceMeta, selectedDataSourceIcon, creatingApp } = this.state;
return (
<div className="sample-db-modal-body">
<div className="row sample-db-title" data-cy="sample-db-postgres-title">
<div className="col-md-1">
{getSvgIcon(dataSourceMeta?.kind?.toLowerCase(), 35, 35, selectedDataSourceIcon)}
</div>
<div className="col-md-1">PostgreSQL</div>
</div>
<div className={'sample-db-description'} data-cy="sample-db-description">
<p className={`p ${this.props.darkMode ? 'dark' : ''}`}>
This PostgreSQL data source is a shared resource and may show varying data
<br /> due to real-time updates. It&apos;s reset daily for some consistency, but please note <br />
it&apos;s designed for user exploration, not production use.
</p>
</div>
<div className="create-btn-cont">
<ButtonSolid
className={`create-app-btn`}
data-cy="create-sample-app-button"
isLoading={creatingApp}
// disabled={isSaving || this.props.isVersionReleased || isSaveDisabled}
variant="primary"
onClick={this.createSampleApp}
fill={this.props.darkMode && this.props.isVersionReleased ? '#4c5155' : '#FDFDFE'}
>
Create sample application
</ButtonSolid>
</div>
<div className="image-container">
<img
src="assets/images/Sample data source.png"
className="img-sample-db"
alt="Sample data source"
data-cy="sample-db-image"
/>
</div>
</div>
);
};
renderCardGroup = (source, type) => {
const openDataSourceConfirmModal = (dataSource) =>
this.setState({
dataSourceConfirmModalProps: {
isOpen: true,
dataSource,
category: type,
},
});
if (this.state.queryString && this.state.queryString.length > 0) {
const filteredDatasources = this.state.filteredDatasources.map((datasource) => {
const src = datasource?.iconFile?.data
? `data:image/svg+xml;base64,${datasource.iconFile?.data}`
: datasource?.kind?.toLowerCase();
return {
...datasource,
src,
title: datasource.name,
};
});
return (
<>
<div className="row row-deck mt-4 ">
<h4 className="mb-2">{type}</h4>
{filteredDatasources.map((item) => (
<Card
key={item.key}
title={item.title}
src={item.src}
handleClick={() => openDataSourceConfirmModal(item)}
usePluginIcon={isEmpty(item?.iconFile?.data)}
height="35px"
width="35px"
/>
))}
</div>
</>
);
}
if (type === 'All Datasources') {
const databases = source.databases.map((datasource) => {
return {
...datasource,
src: datasource?.kind?.toLowerCase(),
title: datasource.name,
};
});
const apis = source.apis.map((datasource) => {
return {
...datasource,
src: datasource?.kind?.toLowerCase(),
title: datasource.name,
};
});
const cloudStorages = source.cloudStorages.map((datasource) => {
return {
...datasource,
src: datasource?.kind?.toLowerCase(),
title: datasource.name,
};
});
return (
<>
<div>
<div className="row row-deck mt-4">
<h4 className="mb-2">{'Databases'}</h4>
{databases.map((item) => (
<Card
key={item.key}
title={item.title}
src={item.src}
handleClick={() => openDataSourceConfirmModal(item)}
usePluginIcon={true}
height="35px"
width="35px"
/>
))}
</div>
</div>
<div>
<div className="row row-deck mt-4">
<h4 className="mb-2">{'APIs'}</h4>
{apis.map((item) => (
<Card
key={item.key}
title={item.title}
src={item.src}
handleClick={() => openDataSourceConfirmModal(item)}
usePluginIcon={true}
height="35px"
width="35px"
/>
))}
</div>
</div>
<div>
<div className="row row-deck mt-4">
<h4 className="mb-2">{'Cloud Storages'}</h4>
{cloudStorages.map((item) => (
<Card
key={item.key}
title={item.title}
src={item.src}
handleClick={() => openDataSourceConfirmModal(item)}
usePluginIcon={true}
height="35px"
width="35px"
/>
))}
</div>
</div>
</>
);
}
const datasources = source.map((datasource) => {
const src = datasource?.iconFile?.data
? `data:image/svg+xml;base64,${datasource.iconFile?.data}`
: datasource?.kind?.toLowerCase();
return {
...datasource,
src,
title: datasource.name,
};
});
return (
<>
<div className="row row-deck mt-4">
<h4 className="mb-2">{type}</h4>
{datasources.map((item) => (
<Card
key={item.key}
title={item.title}
src={item?.src}
handleClick={() => openDataSourceConfirmModal(item)}
usePluginIcon={isEmpty(item?.iconFile?.data)}
height="35px"
width="35px"
/>
))}
</div>
</>
);
};
renderEnvironmentsTab = (selectedDataSource) => {
const multiEnvironmentEnabled = this.props?.featureAccess?.multiEnvironment;
const isTrial = this.props?.featureAccess?.licenseStatus?.licenseType === 'trial';
const licenseValid =
!this.props?.featureAccess?.licenseStatus?.isExpired && this.props?.featureAccess?.licenseStatus?.isLicenseValid;
return (
selectedDataSource &&
this.props.environments?.length > 1 && (
<nav className="nav nav-tabs mt-3">
{this.props?.environments.map((env, key) => {
const Wrapper = ({ children }) =>
!env?.enabled ? (
<LicenseTooltip
placement="bottom"
feature={'multi-environments'}
isAvailable={env?.enabled}
noTooltipIfValid={true}
customMessage={
!this.props?.featureAccess?.isLicenseValid || this.props?.featureAccess?.isExpired
? 'Multi-environments are available only in paid plans'
: 'Multi-environments are not included in your current plan'
}
>
{children}
</LicenseTooltip>
) : (
<>{children}</>
);
return (
<Wrapper key={key}>
<a
key={env?.id}
onClick={() =>
this.props.handleActions(() => {
if (env?.enabled) {
!selectedDataSource?.id && this.resetOptions();
this.props.environmentChanged(env, selectedDataSource?.id);
}
})
}
disabled={!env?.enabled}
className={cx('nav-item nav-link', { active: this.props.currentEnvironment?.name === env.name })}
data-cy={`${env.name}-label`}
>
<ToolTip
message={'Multi-environments is a paid plan feature'}
show={isTrial && licenseValid}
placement="bottom"
>
<div className="d-flex align-items-center">
{capitalize(env.name)}
{env.priority > 1 && (!multiEnvironmentEnabled || isTrial) && (
<SolidIcon className="mx-1" name="enterprisesmall" />
)}
</div>
</ToolTip>
</a>
</Wrapper>
);
})}
</nav>
)
);
};
render() {
const {
dataSourceMeta,
selectedDataSource,
selectedDataSourceIcon,
options,
isSaving,
connectionTestError,
isCopied,
dataSourceSchema,
pluginsLoaded,
dataSourceConfirmModalProps,
addingDataSource,
datasourceName,
validationError,
validationMessages,
} = this.state;
const isPlugin = dataSourceSchema ? true : false;
const createSelectedDataSource = (dataSource, category) => {
this.selectDataSource(dataSource, category);
};
const isSampleDb = selectedDataSource?.type === DATA_SOURCE_TYPE.SAMPLE;
const sampleDBmodalBodyStyle = isSampleDb ? { paddingBottom: '0px', borderBottom: '1px solid #E6E8EB' } : {};
const sampleDBmodalFooterStyle = isSampleDb ? { paddingTop: '8px' } : {};
const isSaveDisabled = selectedDataSource
? (deepEqual(options, selectedDataSource?.options, ['encrypted']) &&
selectedDataSource?.name === datasourceName) ||
!isEmpty(validationMessages)
: true;
this.props.setGlobalDataSourceStatus({ isEditing: !isSaveDisabled });
const docLink = isSampleDb
? 'https://docs.tooljet.ai/docs/data-sources/sample-data-sources'
: selectedDataSource?.pluginId && selectedDataSource.pluginId.trim() !== ''
? `https://docs.tooljet.ai/docs/marketplace/plugins/marketplace-plugin-${selectedDataSource?.kind}/`
: `https://docs.tooljet.ai/docs/data-sources/${selectedDataSource?.kind}`;
const OAuthDs = [
'slack',
'zendesk',
'googlesheets',
'salesforce',
'googlecalendar',
'snowflake',
'microsoft_graph',
'hubspot',
];
return (
pluginsLoaded && (
<div>
<Modal
show={this.props.showDataSourceManagerModal}
size={selectedDataSource ? 'lg' : 'xl'}
onEscapeKeyDown={this.hideModal}
className={selectedDataSource ? 'animation-fade' : 'select-datasource-list-modal animation-fade'}
contentClassName={`${this.props.darkMode ? 'dark-theme' : ''}`}
animation={false}
onExit={this.onExit}
container={this.props.container}
{...this.props.modalProps}
>
<Modal.Header className={'d-block'}>
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex">
{selectedDataSource && this.props.showBackButton && (
<div
className={`back-btn me-3 mt-3 ${this.props.darkMode ? 'dark' : ''}`}
role="button"
onClick={() => this.setState({ selectedDataSource: false }, () => this.onExit())}
>
<img
data-cy="button-back-ds-connection-modal"
className="m-0"
src="assets/images/icons/back.svg"
width="30"
height="30"
/>
</div>
)}
<Modal.Title className="mt-3">
{selectedDataSource && !isSampleDb ? (
<div className="row selected-ds img-container">
{getSvgIcon(dataSourceMeta?.kind?.toLowerCase(), 35, 35, selectedDataSourceIcon)}
<div className="input-icon" style={{ width: '160px' }}>
<input
type="text"
onChange={(e) => this.onNameChanged(e.target.value)}
className="form-control-plaintext form-control-plaintext-sm color-slate12 tw-border-x tw-border-y"
value={decodeEntities(selectedDataSource.name)}
style={{ width: '160px' }}
data-cy="data-source-name-input-field"
autoFocus
autoComplete="off"
disabled={!canUpdateDataSource(selectedDataSource.id)}
/>
{!this.props.isEditing && (
<span className="input-icon-addon">
<img src="assets/images/icons/edit-source.svg" width="12" height="12" />
</span>
)}
</div>
</div>
) : (
<div className="row">
<div className="col-md-2">
<img src="assets/images/tj-logo.svg" />
</div>
<div className="col-md-10" data-cy="sample-data-source-title">
{' '}
Sample data source
</div>
</div>
)}
{!selectedDataSource && (
<span className="" data-cy="title-add-new-datasource">
{this.props.t('editor.queryManager.dataSourceManager.addNewDataSource', 'Add new datasource')}
</span>
)}
</Modal.Title>
{!this.props.isEditing && (
<span
data-cy="button-close-ds-connection-modal"
className={`close-btn mx-4 mt-3 ${this.props.darkMode ? 'dark' : ''}`}
onClick={() => this.hideModal()}
>
<SolidIcon name="remove" width="20" fill={'var(--slate12)'} />
</span>
)}
</div>
<div className="tw-pt-[15px]">
{this.props.tags &&
this.props.tags.map((tag) => {
if (tag === 'AI') {
return (
<div key={tag} className="tag-container">
<SolidIcon name="AI-tag" />
<span>{tag}</span>
</div>
);
}
})}
</div>
</div>
{!isSampleDb && (
<MultiEnvTabs
selectedDataSource={selectedDataSource}
environments={this.props.environments}
featureAccess={this.props.featureAccess}
currentEnvironment={this.props.currentEnvironment}
handleActions={this.props.handleActions}
environmentChanged={this.props.environmentChanged}
resetOptions={this.resetOptions}
/>
)}
</Modal.Header>
{this.props.environmentLoading ? (
<ModalBody>
<DataSourceLoader />
</ModalBody>
) : (
<>
<Modal.Body style={sampleDBmodalBodyStyle}>
{selectedDataSource && !isSampleDb ? (
<div className="dataSourceWrapper">
{this.renderSourceComponent(selectedDataSource.kind, isPlugin)}
</div>
) : (
selectedDataSource &&
isSampleDb && <div className="dataSourceWrapper">{this.renderSampleDBModal()}</div>
)}
{!selectedDataSource &&
this.segregateDataSources(this.state.suggestingDatasources, this.props.darkMode)}
</Modal.Body>
{selectedDataSource &&
!dataSourceMeta.customTesting &&
(!OAuthDs.includes(selectedDataSource?.kind) ||
!(
options?.auth_type?.value === 'oauth2' &&
options?.grant_type?.value === 'authorization_code' &&
options?.multiple_auth_enabled?.value !== true
)) && (
<Modal.Footer style={sampleDBmodalFooterStyle} className="modal-footer-class">
{selectedDataSource && !isSampleDb && (
<div className="row w-100">
<div className="card-body datasource-footer-info">
<div className="row">
<div className="col-1">
<SolidIcon name="information" fill="#3E63DD" />
</div>
<div className="col" style={{ maxWidth: '480px' }}>
<p data-cy="white-list-ip-text" className="tj-text">
{this.props.t(
'editor.queryManager.dataSourceManager.whiteListIP',
'Please white-list our IP address if the data source is not publicly accessible.'
)}
</p>
</div>
<div className="col-auto">
{isCopied ? (
<center className="my-2">
<span className="copied" data-cy="label-ip-copied">
{this.props.t('editor.queryManager.dataSourceManager.copied', 'Copied')}
</span>
</center>
) : (
<CopyToClipboard
text={config.SERVER_IP}
onCopy={() => {
this.setState({ isCopied: true });
}}
>
<ButtonSolid
type="button"
className={`datasource-copy-button`}
data-cy="button-copy-ip"
variant="tertiary"
leftIcon="copy"
iconWidth="12"
>
{this.props.t('editor.queryManager.dataSourceManager.copy', 'Copy')}
</ButtonSolid>
</CopyToClipboard>
)}
</div>
</div>
</div>
</div>
)}
{connectionTestError && (
<div className="row w-100">
<div className="alert alert-danger" role="alert">
<div className="text-muted" data-cy="connection-alert-text">
{connectionTestError.message}
</div>
</div>
</div>
)}
{validationError && validationError.length > 0 && (
<div className="row w-100">
<div className="alert alert-danger" role="alert">
{validationError.map((error, index) => (
<div
key={index}
className="text-muted"
data-cy={`${generateCypressDataCy(error)}-field-alert-text`}
>
{error}
</div>
))}
</div>
</div>
)}
<div className="col">
<SolidIcon name="logs" fill="#3E63DD" width="20" style={{ marginRight: '8px' }} />
<a
className="color-primary tj-docs-link tj-text-sm"
href={docLink}
target="_blank"
rel="noreferrer"
data-cy="link-read-documentation"
>
{this.props.t('globals.readDocumentation', 'Read documentation')}
</a>
</div>
<div
className={!isSampleDb ? `col-auto` : 'col-auto test-connection-sample-db'}
data-cy="button-test-connection"
>
<TestConnection
kind={selectedDataSource?.kind}
pluginId={selectedDataSource?.pluginId ?? this.state.selectedDataSourcePluginId}
options={options}
onConnectionTestFailed={this.onConnectionTestFailed}
darkMode={this.props.darkMode}
environmentId={this.props.currentEnvironment?.id}
dataSourceId={selectedDataSource?.id}
dataSourceType={selectedDataSource?.type}
appId={this.state.appId}
/>
</div>
{!isSampleDb && (
<div className="col-auto" data-cy="db-connection-save-button">
<ButtonSolid
className={`m-2 ${isSaving ? 'btn-loading' : ''}`}
isLoading={isSaving}
disabled={isSaving || this.props.isVersionReleased || isSaveDisabled}
variant="primary"
onClick={this.createDataSource}
leftIcon="floppydisk"
fill={this.props.darkMode && this.props.isVersionReleased ? '#4c5155' : '#FDFDFE'}
>
{this.props.t('globals.save', 'Save')}
</ButtonSolid>
</div>
)}
</Modal.Footer>
)}
{!dataSourceMeta?.hideSave &&
selectedDataSource &&
dataSourceMeta.customTesting &&
(!OAuthDs.includes(selectedDataSource?.kind) ||
!(
options?.auth_type?.value === 'oauth2' &&
options?.grant_type?.value === 'authorization_code' &&
options?.multiple_auth_enabled?.value !== true
)) && (
<Modal.Footer>
<div className="col">
<SolidIcon name="logs" fill="#3E63DD" width="20" style={{ marginRight: '8px' }} />
<a
className="color-primary tj-docs-link tj-text-sm"
data-cy="link-read-documentation"
href={
selectedDataSource?.pluginId && selectedDataSource.pluginId.trim() !== ''
? `https://docs.tooljet.ai/docs/marketplace/plugins/marketplace-plugin-${selectedDataSource.kind}/`
: `https://docs.tooljet.ai/docs/data-sources/${selectedDataSource.kind}`
}
target="_blank"
rel="noreferrer"
>
{this.props.t('globals.readDocumentation', 'Read documentation')}
</a>
</div>
<div className="col-auto" data-cy="db-connection-save-button">
<ButtonSolid
leftIcon="floppydisk"
fill={'#FDFDFE'}
className="m-2"
disabled={isSaving || this.props.isVersionReleased || isSaveDisabled}
variant="primary"
onClick={this.createDataSource}
>
{isSaving
? this.props.t('editor.queryManager.dataSourceManager.saving' + '...', 'Saving...')
: this.props.t('globals.save', 'Save')}
</ButtonSolid>
</div>
</Modal.Footer>
)}
</>
)}
</Modal>
<ConfirmDialog
title={'Add datasource'}
show={dataSourceConfirmModalProps.isOpen}
message={`Do you want to add ${dataSourceConfirmModalProps?.dataSource?.name}`}
onConfirm={() =>
createSelectedDataSource(dataSourceConfirmModalProps.dataSource, dataSourceConfirmModalProps?.category)
}
onCancel={this.resetDataSourceConfirmModal}
confirmButtonText={'Add datasource'}
confirmButtonType="primary"
cancelButtonType="tertiary"
backdropClassName="datasource-selection-confirm-backdrop"
confirmButtonLoading={addingDataSource}
/>
</div>
)
);
}
}
const EmptyStateContainer = ({
suggestionUI = false,
queryString,
handleBackToAllDatasources,
darkMode,
placeholder,
}) => {
const { t } = useTranslation();
const [inputValue, set] = React.useState(() => '');
const [status, setStatus] = React.useState(false);
const handleSend = () => {
if (inputValue) {
setStatus(true);
//send value to backend
}
};
React.useEffect(() => {
setStatus(false);
}, [queryString]);
return (
<div className="empty">
{queryString && !suggestionUI && (
<h3>
{t(
`editor.queryManager.dataSourceManager.noResultsFor + "${queryString}"`,
`No results for "${queryString}"`
)}
</h3>
)}
<center className={`empty-results ${suggestionUI ? 'suggestionUI-results' : ''}`}>
<img src="assets/images/icons/no-results.svg" width="150" height="150" />
{status ? (
<div>
<p className="text-success mt-2">
{t('editor.queryManager.dataSourceManager.noteTaken', `Thank you, we've taken a note of that!`)}
</p>
<button
className={`datasource-modal-button ${darkMode && 'dark-button'}`}
onClick={handleBackToAllDatasources}
>
{t('editor.queryManager.dataSourceManager.goToAllDatasources', 'Go to all Datasources')}
</button>
</div>
) : (
<div className="row empty-search">
<div className="col-9 mt-2">
<div className="input-icon">
<input
type="text"
className="form-control mb-2"
value={inputValue}
placeholder={placeholder}
onChange={(e) => set(e.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSend();
}
}}
/>
</div>
</div>
<div className="col-auto">
<Button className="mt-2" disabled={!inputValue.length} variant="primary" onClick={handleSend}>
{t('editor.queryManager.dataSourceManager.send', 'Send')}
</Button>
</div>
</div>
)}
</center>
</div>
);
};
const SearchBoxContainer = ({ onChange, onClear, queryString, activeDatasourceList, dataCy, scope }) => {
const [searchText, setSearchText] = React.useState(queryString ?? '');
const { t } = useTranslation();
const handleChange = (e) => {
setSearchText(e.target.value);
onChange(e.target.value, activeDatasourceList);
};
const clearSearch = () => {
setSearchText('');
onClear();
};
React.useEffect(() => {
if (searchText.length > 0) {
onChange(searchText, activeDatasourceList);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDatasourceList]);
React.useEffect(() => {
if (queryString === null) {
setSearchText('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryString]);
React.useEffect(() => {
if (searchText === '') {
onClear();
}
let element = document.querySelector('.input-icon .form-control:not(:first-child)');
if (scope === 'global') {
element = document.querySelector('.input-icon .form-control');
}
if (searchText && element) {
element.style.paddingLeft = '0.5rem';
}
return () => {
element && (element.style.paddingLeft = '2.5rem');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchText]);
return (
<div className="search-box-wrapper">
<div style={{ height: '36px' }} className="input-icon d-flex">
{searchText.length === 0 && (
<span className="search-icon mt-2 mx-2">
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="10" cy="10" r="7" />
<line x1="21" y1="21" x2="15" y2="15" />
</svg>
</span>
)}
<input
type="text"
value={searchText}
onChange={handleChange}
className="form-control"
placeholder={t('globals.search', 'Search')}
autoFocus
data-cy={dataCy}
/>
{searchText.length > 0 && (
<span className="clear-icon mt-2" onClick={clearSearch}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-circle-x"
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="12" cy="12" r="9"></circle>
<path d="M10 10l4 4m0 -4l-4 4"></path>
</svg>
</span>
)}
</div>
</div>
);
};
const DataSourceLoader = () => {
const generateLoaders = (max = 7) => {
const arr = [];
for (let i = 1; i < max; i++) {
arr.push(
<div key={i}>
<Skeleton className="label-loader" />
<Skeleton className="input-loader" />
</div>
);
}
return arr;
};
return <div className="data-source-loader-container">{generateLoaders()}</div>;
};
const withStore = (Component) => (props) => {
const { setGlobalDataSourceStatus } = useDataSourcesStore(
(state) => ({
setGlobalDataSourceStatus: state.actions.setGlobalDataSourceStatus,
}),
shallow
);
const { handleActions } = useGlobalDatasourceUnsavedChanges();
return <Component {...props} setGlobalDataSourceStatus={setGlobalDataSourceStatus} handleActions={handleActions} />;
};
export const DataSourceManager = withTranslation()(withRouter(withStore(DataSourceManagerComponent)));