diff --git a/.github/workflows/render-preview-deploy.yml b/.github/workflows/render-preview-deploy.yml
index ead9ba50bf..203ee88150 100644
--- a/.github/workflows/render-preview-deploy.yml
+++ b/.github/workflows/render-preview-deploy.yml
@@ -13,12 +13,42 @@ permissions:
jobs:
# Community Edition
-
create-ce-review-app:
if: ${{ github.event.action == 'labeled' && (github.event.label.name == 'create-ce-review-app' || github.event.label.name == 'review-app') }}
runs-on: ubuntu-latest
steps:
+ - name: Sync repo
+ uses: actions/checkout@v3
+
+ - name: Check if Forked Repository
+ id: check_repo
+ run: |
+ if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
+ echo "is_fork=true" >> $GITHUB_ENV
+ echo "FORKED_OWNER=${{ github.event.pull_request.head.repo.owner.login }}" >> $GITHUB_ENV
+ else
+ echo "is_fork=false" >> $GITHUB_ENV
+ fi
+
+ - name: Set Repository URL
+ run: |
+ if [[ "$is_fork" == "true" ]]; then
+ echo "REPO_URL=https://github.com/${FORKED_OWNER}/ToolJet" >> $GITHUB_ENV
+ else
+ echo "REPO_URL=https://github.com/ToolJet/ToolJet" >> $GITHUB_ENV
+ fi
+
+ - name: Fetch and Checkout Forked Branch
+ if: env.is_fork == 'true'
+ run: |
+ git fetch origin pull/${{ github.event.number }}/head:${{ env.BRANCH_NAME }}
+ git checkout ${{ env.BRANCH_NAME }}
+
+ - name: Checkout Default Branch
+ if: env.is_fork == 'false'
+ uses: actions/checkout@v3
+
- name: Creating deployment for CE
id: create-ce-deployment
run: |
@@ -34,7 +64,7 @@ jobs:
"name": "ToolJet CE PR #${{ env.PR_NUMBER }}",
"notifyOnFail": "default",
"ownerId": "tea-caeo4bj19n072h3dddc0",
- "repo": "https://github.com/ToolJet/ToolJet",
+ "repo": "'"$REPO_URL"'",
"slug": "tooljet-ce-pr-${{ env.PR_NUMBER }}",
"suspended": "not_suspended",
"suspenders": [],
diff --git a/.version b/.version
index 7c69a55dbb..19811903a7 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-3.7.0
+3.8.0
diff --git a/frontend/.version b/frontend/.version
index 7c69a55dbb..19811903a7 100644
--- a/frontend/.version
+++ b/frontend/.version
@@ -1 +1 @@
-3.7.0
+3.8.0
diff --git a/frontend/ee b/frontend/ee
index 715a830c7a..4b950ed3d0 160000
--- a/frontend/ee
+++ b/frontend/ee
@@ -1 +1 @@
-Subproject commit 715a830c7a8d75efc7f77106292d9e4499005b69
+Subproject commit 4b950ed3d0ba15edddf217936e9c9ae1ca3cf11a
diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
index 23d1c7f7cf..3ddf2da932 100644
--- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
+++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
@@ -219,4 +219,11 @@
.react-datepicker__navigation{
overflow: visible !important;
height: inherit !important;
+}
+.tjdb-td-wrapper{
+ .react-datepicker-time__input{
+ input{
+ line-height: normal !important;
+ }
+ }
}
\ No newline at end of file
diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/DateTimePicker.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/DateTimePicker.jsx
index 7a4a0b0bce..874de20b33 100644
--- a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/DateTimePicker.jsx
+++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/DateTimePicker.jsx
@@ -300,7 +300,7 @@ export const DateTimePicker = ({
return (
{
diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
index 55d0e7f3ed..44eecf6ac5 100644
--- a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
+++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker/styles.scss
@@ -117,6 +117,9 @@
margin-top: 4px;
box-shadow: 0px 8px 16px 0px #3032331A;
}
+.react-datepicker-popper {
+ z-index: 10001 !important;
+}
.react-datepicker-time__caption{
margin-left:20px
@@ -234,4 +237,11 @@
line-height: normal !important;
}
}
-}
\ No newline at end of file
+}
+.table-schema-row{
+ .react-datepicker-time__input-container{
+ input{
+ line-height: normal !important;
+ }
+ }
+}
diff --git a/frontend/src/HomePage/ExportAppModal.jsx b/frontend/src/HomePage/ExportAppModal.jsx
index 463687421a..1ccb7734fc 100644
--- a/frontend/src/HomePage/ExportAppModal.jsx
+++ b/frontend/src/HomePage/ExportAppModal.jsx
@@ -70,7 +70,7 @@ export default function ExportAppModal({ title, show, closeModal, customClassNam
});
}
- if (item.kind === 'tooljetdb' && item.options.table_id) extractedIdData.push(item.options.table_id);
+ if (item.kind === 'tooljetdb' && item.options.tableId) extractedIdData.push(item.options.tableId);
});
const uniqueSet = new Set(extractedIdData);
const selectedVersiontable = Array.from(uniqueSet).map((item) => ({ table_id: item }));
diff --git a/frontend/src/HomePage/HomePage.jsx b/frontend/src/HomePage/HomePage.jsx
index ab50cbb05d..8ee5ab3cc8 100644
--- a/frontend/src/HomePage/HomePage.jsx
+++ b/frontend/src/HomePage/HomePage.jsx
@@ -8,6 +8,7 @@ import {
libraryAppService,
gitSyncService,
licenseService,
+ pluginsService,
} from '@/_services';
import { ConfirmDialog, AppModal } from '@/_components';
import Select from '@/_ui/Select';
@@ -113,7 +114,7 @@ class HomePageComponent extends React.Component {
showUserGroupMigrationModal: false,
showGroupMigrationBanner: true,
shouldAutoImportPlugin: false,
- dependentPluginsForTemplate: [],
+ dependentPlugins: [],
dependentPluginsDetail: {},
};
}
@@ -310,7 +311,7 @@ class HomePageComponent extends React.Component {
const fileReader = new FileReader();
const fileName = file.name.replace('.json', '').substring(0, 50);
fileReader.readAsText(file, 'UTF-8');
- fileReader.onload = (event) => {
+ fileReader.onload = async (event) => {
const result = event.target.result;
let fileContent;
try {
@@ -319,8 +320,26 @@ class HomePageComponent extends React.Component {
toast.error(`Could not import: ${parseError}`);
return;
}
- this.setState({ fileContent, fileName, showImportAppModal: true });
+
+ const importedAppDef = fileContent.app || fileContent.appV2;
+ const dataSourcesUsedInApps = [];
+ importedAppDef.forEach((appDefinition) => {
+ appDefinition?.definition?.appV2?.dataSources.forEach((dataSource) => {
+ dataSourcesUsedInApps.push(dataSource);
+ });
+ });
+
+ const dependentPluginsResponse = await pluginsService.findDependentPlugins(dataSourcesUsedInApps);
+ const { pluginsToBeInstalled = [], pluginsListIdToDetailsMap = {} } = dependentPluginsResponse.data;
+ this.setState({
+ fileContent,
+ fileName,
+ showImportAppModal: true,
+ dependentPlugins: pluginsToBeInstalled,
+ dependentPluginsDetail: { ...pluginsListIdToDetailsMap },
+ });
};
+
fileReader.onerror = (error) => {
toast.error(`Could not import the app: ${error}`);
return;
@@ -348,12 +367,19 @@ class HomePageComponent extends React.Component {
importJSON.app[0].appName = appName;
}
const requestBody = { organization_id, ...importJSON };
+ let installedPluginsInfo = [];
try {
+ if (this.state.dependentPlugins.length) {
+ ({ installedPluginsInfo = [] } = await pluginsService.installDependentPlugins(
+ this.state.dependentPlugins,
+ true
+ ));
+ }
+
const data = await appsService.importResource(requestBody);
toast.success('App imported successfully.');
- this.setState({
- isImportingApp: false,
- });
+ this.setState({ isImportingApp: false });
+
if (!isEmpty(data.imports.app)) {
this.props.navigate(`/${getWorkspaceId()}/apps/${data.imports.app[0].id}`, {
state: { commitEnabled: this.state.commitEnabled },
@@ -362,12 +388,13 @@ class HomePageComponent extends React.Component {
this.props.navigate(`/${getWorkspaceId()}/database`);
}
} catch (error) {
- this.setState({
- isImportingApp: false,
- });
- if (error.statusCode === 409) {
- return false;
+ if (installedPluginsInfo.length) {
+ const pluginsId = installedPluginsInfo.map((pluginInfo) => pluginInfo.id);
+ await pluginsService.uninstallPlugins(pluginsId);
}
+
+ this.setState({ isImportingApp: false });
+ if (error.statusCode === 409) return false;
toast.error(error?.error || error?.message || 'App import failed');
}
};
@@ -380,7 +407,7 @@ class HomePageComponent extends React.Component {
const data = await libraryAppService.deploy(
id,
appName,
- this.state.dependentPluginsForTemplate,
+ this.state.dependentPlugins,
this.state.shouldAutoImportPlugin
);
this.setState({ deploying: false });
@@ -732,7 +759,7 @@ class HomePageComponent extends React.Component {
selectedTemplate: template,
...(plugins_to_be_installed.length && {
shouldAutoImportPlugin: true,
- dependentPluginsForTemplate: plugins_to_be_installed,
+ dependentPlugins: plugins_to_be_installed,
dependentPluginsDetail: { ...plugins_detail_by_id },
}),
});
@@ -750,7 +777,7 @@ class HomePageComponent extends React.Component {
this.setState({
showCreateAppFromTemplateModal: false,
selectedTemplate: null,
- dependentPluginsForTemplate: [],
+ dependentPlugins: [],
dependentPluginsDetail: {},
shouldAutoImportPlugin: false,
});
@@ -763,6 +790,20 @@ class HomePageComponent extends React.Component {
closeCreateAppModal = () => {
this.setState({ showCreateAppModal: false, showCreateModuleModal: false });
};
+
+ openImportAppModal = async () => {
+ this.setState({ showImportAppModal: true });
+ };
+
+ closeImportAppModal = () => {
+ this.setState({
+ showImportAppModal: false,
+ dependentPlugins: [],
+ dependentPluginsDetail: {},
+ shouldAutoImportPlugin: false,
+ });
+ };
+
isWithinSevenDaysOfSignUp = (date) => {
const currentDate = new Date().toISOString();
const differenceInTime = new Date(currentDate).getTime() - new Date(date).getTime();
@@ -836,7 +877,7 @@ class HomePageComponent extends React.Component {
workflowInstanceLevelLimit,
showUserGroupMigrationModal,
showGroupMigrationBanner,
- dependentPluginsForTemplate,
+ dependentPlugins,
dependentPluginsDetail,
} = this.state;
const modalConfigs = {
@@ -865,12 +906,14 @@ class HomePageComponent extends React.Component {
modalType: 'import',
closeModal: () => this.setState({ showImportAppModal: false }),
processApp: this.importFile,
- show: () => this.setState({ showImportAppModal: true }),
+ show: this.openImportAppModal,
title: 'Import app',
actionButton: 'Import app',
actionLoadingButton: 'Importing',
fileContent: fileContent,
selectedAppName: fileName,
+ dependentPluginsDetail: dependentPluginsDetail,
+ dependentPlugins: dependentPlugins,
},
template: {
modalType: 'template',
@@ -882,7 +925,7 @@ class HomePageComponent extends React.Component {
actionLoadingButton: 'Creating',
templateDetails: this.state.selectedTemplate,
dependentPluginsDetail: dependentPluginsDetail,
- dependentPluginsForTemplate: dependentPluginsForTemplate,
+ dependentPlugins: dependentPlugins,
},
};
return (
diff --git a/frontend/src/_components/AppModal.jsx b/frontend/src/_components/AppModal.jsx
index 7d45e4c36e..54c623dbed 100644
--- a/frontend/src/_components/AppModal.jsx
+++ b/frontend/src/_components/AppModal.jsx
@@ -29,7 +29,7 @@ export function AppModal({
handleCommitEnableChange,
appType,
dependentPluginsDetail = [],
- dependentPluginsForTemplate = [],
+ dependentPlugins = [],
}) {
if (!selectedAppName && templateDetails) {
selectedAppName = templateDetails?.name || '';
@@ -238,10 +238,10 @@ export function AppModal({
)}
- {dependentPluginsForTemplate && dependentPluginsForTemplate.length >= 1 && (
+ {dependentPlugins && dependentPlugins.length >= 1 && (
diff --git a/frontend/src/_components/DynamicFormV2.jsx b/frontend/src/_components/DynamicFormV2.jsx
new file mode 100644
index 0000000000..1a6269976f
--- /dev/null
+++ b/frontend/src/_components/DynamicFormV2.jsx
@@ -0,0 +1,510 @@
+import React from 'react';
+import cx from 'classnames';
+import DataSourceSchemaManager from '@/_helpers/dataSourceSchemaManager';
+import Textarea from '@/_ui/Textarea';
+import Input from '@/_ui/Input';
+import Select from '@/_ui/Select';
+import Headers from '@/_ui/HttpHeaders';
+import Toggle from '@/_ui/Toggle';
+import InputV3 from '@/_ui/Input-V3';
+import { filter, find, isEmpty } from 'lodash';
+import { ButtonSolid } from './AppButton';
+import { useGlobalDataSourcesStatus } from '@/_stores/dataSourcesStore';
+import { canDeleteDataSource, canUpdateDataSource } from '@/_helpers';
+import { OverlayTrigger, Tooltip } from 'react-bootstrap';
+import { orgEnvironmentVariableService, orgEnvironmentConstantService } from '../_services';
+import { Constants } from '@/_helpers/utils';
+
+const DynamicFormV2 = ({
+ schema,
+ options,
+ optionchanged,
+ optionsChanged,
+ selectedDataSource,
+ isEditMode,
+ layout = 'vertical',
+ onBlur,
+ setDefaultOptions,
+ currentAppEnvironmentId,
+ isGDS,
+ validationMessages,
+ setValidationMessages,
+ clearValidationMessages,
+}) => {
+ const uiProperties = schema['tj:ui:properties'] || {};
+ const dsm = React.useMemo(() => new DataSourceSchemaManager(schema), [schema]);
+ const encryptedProperties = React.useMemo(() => dsm.getEncryptedProperties(), [dsm]);
+ const [conditionallyRequiredProperties, setConditionallyRequiredProperties] = React.useState([]);
+ const [workspaceVariables, setWorkspaceVariables] = React.useState([]);
+ const [currentOrgEnvironmentConstants, setCurrentOrgEnvironmentConstants] = React.useState([]);
+ const [computedProps, setComputedProps] = React.useState({});
+ const [hasUserInteracted, setHasUserInteracted] = React.useState(false);
+ const [interactedFields, setInteractedFields] = React.useState(new Set());
+
+ const isHorizontalLayout = layout === 'horizontal';
+ const prevDataSourceIdRef = React.useRef(selectedDataSource?.id);
+
+ const globalDataSourcesStatus = useGlobalDataSourcesStatus();
+ const { isEditing: isDataSourceEditing } = globalDataSourcesStatus;
+
+ React.useEffect(() => {
+ if (isGDS) {
+ orgEnvironmentConstantService.getConstantsFromEnvironment(currentAppEnvironmentId).then((data) => {
+ const constants = {
+ globals: {},
+ secrets: {},
+ };
+ data.constants.forEach((constant) => {
+ if (constant.type === Constants.Secret) {
+ constants.secrets[constant.name] = constant.value;
+ } else {
+ constants.globals[constant.name] = constant.value;
+ }
+ });
+
+ setCurrentOrgEnvironmentConstants(constants);
+ });
+
+ orgEnvironmentVariableService.getVariables().then((data) => {
+ const client_variables = {};
+ const server_variables = {};
+ data.variables.map((variable) => {
+ if (variable.variable_type === 'server') {
+ server_variables[variable.variable_name] = 'HiddenEnvironmentVariable';
+ } else {
+ client_variables[variable.variable_name] = variable.value;
+ }
+ });
+
+ setWorkspaceVariables({ client: client_variables, server: server_variables });
+ });
+ }
+
+ return () => {
+ setWorkspaceVariables([]);
+ setCurrentOrgEnvironmentConstants([]);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [currentAppEnvironmentId]);
+
+ React.useEffect(() => {
+ if (!hasUserInteracted) return;
+ const { valid, errors } = dsm.validateData(options);
+
+ if (valid) {
+ clearValidationMessages();
+ } else {
+ setValidationMessages(errors, schema);
+ const requiredFields = errors
+ .filter((error) => error.keyword === 'required')
+ .map((error) => error.params.missingProperty);
+ setConditionallyRequiredProperties(requiredFields);
+ }
+ }, [options]);
+
+ React.useEffect(() => {
+ const prevDataSourceId = prevDataSourceIdRef.current;
+ prevDataSourceIdRef.current = selectedDataSource?.id;
+ const uiProperties = schema['tj:ui:properties'];
+ if (!isEmpty(uiProperties)) {
+ let fields = {};
+ let encryptedFieldsProps = {};
+ const flipComponentDropdown = find(uiProperties, ['widget', 'dropdown-component-flip']);
+
+ if (flipComponentDropdown) {
+ const selector = options?.[flipComponentDropdown?.key]?.value;
+ const commonFieldsFromSslCertificate = uiProperties[selector]?.ssl_certificate?.commonFields;
+ fields = {
+ ...commonFieldsFromSslCertificate,
+ ...flipComponentDropdown?.commonFields,
+ ...uiProperties[selector],
+ };
+ } else {
+ fields = { ...uiProperties };
+ }
+
+ const processFields = (fieldsObject) => {
+ Object.keys(fieldsObject).forEach((key) => {
+ const field = fieldsObject[key];
+ const { widget, encrypted, key: propertyKey } = field;
+
+ if (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()) {
+ encryptedFieldsProps[propertyKey] = {
+ disabled: !!selectedDataSource?.id,
+ };
+ } else if (!isDataSourceEditing) {
+ if (widget === 'password' || encrypted) {
+ encryptedFieldsProps[propertyKey] = {
+ disabled: true,
+ };
+ }
+ } else {
+ if ((widget === 'password' || encrypted) && !(propertyKey in computedProps)) {
+ encryptedFieldsProps[propertyKey] = {
+ disabled: !!selectedDataSource?.id,
+ };
+ }
+ }
+
+ // To check for nested dropdown-component-flip
+ if (widget === 'dropdown-component-flip') {
+ const selectedOption = options?.[field.key]?.value;
+
+ if (field.commonFields) {
+ processFields(field.commonFields);
+ }
+
+ if (selectedOption && fieldsObject[selectedOption]) {
+ processFields(fieldsObject[selectedOption]);
+ }
+ }
+ });
+ };
+
+ processFields(fields);
+
+ if (uiProperties.renderForm) {
+ Object.keys(uiProperties.renderForm).forEach((sectionKey) => {
+ const section = uiProperties.renderForm[sectionKey];
+ const { inputs } = section;
+ if (inputs) {
+ processFields(inputs);
+ }
+ });
+ }
+
+ if (prevDataSourceId !== selectedDataSource?.id) {
+ setComputedProps({ ...encryptedFieldsProps });
+ } else {
+ setComputedProps({ ...computedProps, ...encryptedFieldsProps });
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedDataSource?.id, options, isDataSourceEditing]);
+
+ const getElement = (type) => {
+ switch (type) {
+ case 'password':
+ case 'text':
+ return Input;
+ case 'password-v3':
+ case 'text-v3':
+ return InputV3;
+ case 'textarea':
+ return Textarea;
+ case 'toggle':
+ return Toggle;
+ case 'react-component-headers':
+ return Headers;
+ // TODO: Move dropdown component flip logic to be handled here
+ // case 'dropdown-component-flip':
+ // return Select;
+ default:
+ return Type is invalid
;
+ }
+ };
+
+ const getElementProps = (uiProperties) => {
+ const { label, description, widget, required, width, key, help_text: helpText, list, buttonText } = uiProperties;
+
+ const isRequired = required || conditionallyRequiredProperties.includes(key);
+ const isEncrypted = widget === 'password-v3' || encryptedProperties.includes(key);
+ const currentValue = options?.[key]?.value;
+
+ const handleOptionChange = (key, value, flag) => {
+ if (!hasUserInteracted) {
+ setHasUserInteracted(true);
+ }
+ setInteractedFields((prev) => new Set(prev).add(key));
+ optionchanged(key, value, flag);
+ };
+
+ switch (widget) {
+ case 'password':
+ case 'text':
+ case 'textarea': {
+ return {
+ key,
+ widget,
+ label,
+ placeholder: isEncrypted ? '**************' : description,
+ className: cx('form-control', {
+ 'dynamic-form-encrypted-field': isEncrypted,
+ }),
+ style: { marginBottom: '0px !important' },
+ helpText: helpText,
+ value: currentValue || '',
+ onChange: (e) => optionchanged(key, e.target.value, true),
+ isGDS: true,
+ workspaceVariables: [],
+ workspaceConstants: [],
+ encrypted: isEncrypted,
+ onBlur,
+ };
+ }
+ case 'password-v3':
+ case 'text-v3': {
+ return {
+ key,
+ widget,
+ label,
+ placeholder: isEncrypted ? '**************' : description,
+ className: cx('form-control', {
+ 'dynamic-form-encrypted-field': isEncrypted,
+ }),
+ style: { marginBottom: '0px !important' },
+ helpText: helpText,
+ value: currentValue || '',
+ onChange: (e) => handleOptionChange(key, e.target.value, true),
+ isGDS: true,
+ workspaceVariables: [],
+ workspaceConstants: [],
+ encrypted: isEncrypted,
+ onBlur,
+ isRequired: isRequired,
+ isValidatedMessages:
+ !hasUserInteracted || !interactedFields.has(key)
+ ? { valid: null, message: '' } // skip validation for initial render and untouched elements
+ : validationMessages[key]
+ ? { valid: false, message: validationMessages[key] }
+ : isRequired && !isEncrypted
+ ? { valid: true, message: '' }
+ : { valid: null, message: '' }, // handle optional && encrypted fields
+ isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
+ };
+ }
+ case 'react-component-headers': {
+ let isRenderedAsQueryEditor;
+ if (isGDS) {
+ isRenderedAsQueryEditor = false;
+ } else {
+ isRenderedAsQueryEditor = !isGDS;
+ }
+ return {
+ getter: key,
+ options: isRenderedAsQueryEditor
+ ? options?.[key] ?? schema?.defaults?.[key]
+ : options?.[key]?.value ?? schema?.defaults?.[key]?.value,
+ optionchanged,
+ isRenderedAsQueryEditor,
+ workspaceConstants: currentOrgEnvironmentConstants,
+ isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(),
+ encrypted: isEncrypted,
+ buttonText,
+ width: width,
+ };
+ }
+ case 'toggle':
+ return {
+ defaultChecked: currentValue,
+ checked: currentValue,
+ onChange: (e) => optionchanged(key, e.target.checked),
+ };
+ case 'dropdown':
+ case 'dropdown-component-flip':
+ return {
+ options: list,
+ value: options?.[key]?.value || options?.[key],
+ onChange: (value) => optionchanged(key, value),
+ width: width || '100%',
+ encrypted: options?.[key]?.encrypted,
+ };
+ default:
+ return {};
+ }
+ };
+
+ const getLayout = (uiProperties) => {
+ if (isEmpty(uiProperties)) return null;
+ const flipComponentDropdown = isFlipComponentDropdown(uiProperties);
+
+ if (flipComponentDropdown) {
+ return flipComponentDropdown;
+ }
+
+ const handleEncryptedFieldsToggle = (event, field) => {
+ if (!canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource()) {
+ return;
+ }
+ const isEditing = computedProps[field]['disabled'];
+ if (isEditing) {
+ optionchanged(field, '');
+ } else {
+ //Send old field value if editing mode disabled for encrypted fields
+ const newOptions = { ...options };
+ const oldFieldValue = selectedDataSource?.['options']?.[field];
+ if (oldFieldValue) {
+ optionsChanged({ ...newOptions, [field]: oldFieldValue });
+ } else {
+ delete newOptions[field];
+ optionsChanged({ ...newOptions });
+ }
+ }
+ setComputedProps({
+ ...computedProps,
+ [field]: {
+ ...computedProps[field],
+ disabled: !isEditing,
+ },
+ });
+ };
+
+ const renderLabel = (label, tooltip) => {
+ const labelElement = (
+
+ );
+
+ if (tooltip) {
+ return (
+ {tooltip}}
+ >
+ {labelElement}
+
+ );
+ }
+
+ return labelElement;
+ };
+
+ return (
+
+ {Object.keys(uiProperties).map((key) => {
+ const { label, widget, encrypted, className, key: propertyKey } = uiProperties[key];
+ const Element = getElement(widget);
+ const isSpecificComponent = ['tooljetdb-operations', 'react-component-api-endpoint'].includes(widget);
+
+ return (
+
+ {!isSpecificComponent && (
+
+ {label &&
+ widget !== 'text-v3' &&
+ widget !== 'password-v3' &&
+ renderLabel(label, uiProperties[key].tooltip)}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ );
+ };
+
+ const FlipComponentDropdown = (uiProperties) => {
+ const flipComponentDropdowns = filter(uiProperties, ['widget', 'dropdown-component-flip']);
+
+ const dropdownComponents = flipComponentDropdowns.map((flipComponentDropdown) => {
+ const selector = options?.[flipComponentDropdown?.key]?.value || options?.[flipComponentDropdown?.key];
+
+ return (
+
+
+ {flipComponentDropdown.commonFields && getLayout(flipComponentDropdown.commonFields)}
+
+
+ {(flipComponentDropdown.label || isHorizontalLayout) && (
+
+ )}
+
+
+
+
+ {flipComponentDropdown.helpText && (
+
{flipComponentDropdown.helpText}
+ )}
+
+
+
+ {getLayout(uiProperties[selector])}
+
+ );
+ });
+
+ const normalComponents = Object.keys(uiProperties).map((key) => {
+ const component = uiProperties[key];
+
+ if (component.type && component.type !== 'dropdown-component-flip') {
+ return {getLayout({ [key]: component })}
;
+ }
+ return null;
+ });
+
+ return (
+ <>
+ {normalComponents}
+ {dropdownComponents}
+ >
+ );
+ };
+
+ const isFlipComponentDropdown = (uiProperties) => {
+ const checkFlipComponents = filter(uiProperties, ['widget', 'dropdown-component-flip']);
+ if (checkFlipComponents.length > 0) {
+ return FlipComponentDropdown(uiProperties);
+ } else {
+ return null;
+ }
+ };
+
+ const flipComponentDropdown = isFlipComponentDropdown(uiProperties);
+ if (flipComponentDropdown) return flipComponentDropdown;
+ return getLayout(uiProperties);
+};
+
+export default DynamicFormV2;
diff --git a/frontend/src/_components/PluginsListForAppModal.jsx b/frontend/src/_components/PluginsListForAppModal.jsx
index 56d7e19504..074e3b9025 100644
--- a/frontend/src/_components/PluginsListForAppModal.jsx
+++ b/frontend/src/_components/PluginsListForAppModal.jsx
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import config from 'config';
-export const PluginsListForAppModal = ({ dependentPluginsForTemplate, dependentPluginsDetail }) => {
+export const PluginsListForAppModal = ({ dependentPlugins, dependentPluginsDetail }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => {
@@ -29,7 +29,7 @@ export const PluginsListForAppModal = ({ dependentPluginsForTemplate, dependentP
)}
- {isExpanded && dependentPluginsForTemplate && dependentPluginsForTemplate.length > 0 && (
+ {isExpanded && dependentPlugins && dependentPlugins.length > 0 && (
- {dependentPluginsForTemplate.map((plugin, index) => {
+ {dependentPlugins.map((plugin, index) => {
const pluginsName = dependentPluginsDetail[plugin].name || plugin;
const iconSrc = `${config.TOOLJET_MARKETPLACE_URL}/marketplace-assets/${plugin}/lib/icon.svg`;
return (
diff --git a/frontend/src/_helpers/dataSourceSchemaManager.js b/frontend/src/_helpers/dataSourceSchemaManager.js
new file mode 100644
index 0000000000..abf2f3c9bb
--- /dev/null
+++ b/frontend/src/_helpers/dataSourceSchemaManager.js
@@ -0,0 +1,119 @@
+import Ajv2020 from 'ajv';
+
+const ajvOptions = {
+ strict: false,
+ allErrors: true,
+ removeAdditional: false,
+ // We disable meta-schema validation to avoid the schema being validated
+ // against the official 2020-12 standard in ways that conflict with custom keywords.
+ validateSchema: false,
+ coerceTypes: true,
+ errorDataPath: 'property',
+};
+
+export default class DataSourceSchemaManager {
+ constructor(schema) {
+ this.schema = schema;
+ this.ajv = new Ajv2020(ajvOptions);
+ this.validate = this.ajv.compile(this.schema);
+ }
+
+ validateData(options) {
+ const data = this._convertDataSourceOptionsToData(options);
+ const valid = this.validate(data);
+ if (!valid) {
+ return { valid: false, errors: this.validate.errors };
+ }
+ return { valid: true, errors: [] };
+ }
+
+ getDefaults(options = {}) {
+ const dataWithDefaults = { ...this._convertDataSourceOptionsToData(options) };
+
+ // AJV does not support defaults with conditional schemas
+ // https://ajv.js.org/guide/modifying-data.html#assigning-defaults
+ // Create a schema without conditional properties for default value assignment
+ const schemaWithoutConditionals = {
+ type: this.schema.type,
+ properties: { ...this.schema.properties },
+ };
+
+ // Compile the schema without conditionals to fill in default values
+ const ajvForDefaults = new Ajv2020({
+ ...ajvOptions,
+ useDefaults: true,
+ });
+ const applyDefaults = ajvForDefaults.compile(schemaWithoutConditionals);
+ applyDefaults(dataWithDefaults);
+
+ const encryptedProperties = this.getEncryptedProperties();
+
+ // Combine the data with defaults and set encrypted fields to null
+ const combinedData = {
+ ...dataWithDefaults,
+ ...Object.fromEntries(encryptedProperties.map((key) => [key, null])),
+ };
+
+ return Object.entries(combinedData).reduce((result, [key, value]) => {
+ result[key] = {
+ value: value,
+ encrypted: encryptedProperties.includes(key),
+ };
+ return result;
+ }, {});
+ }
+
+ getEncryptedProperties() {
+ return this.schema['tj:encrypted'] || [];
+ }
+
+ getSourceMetadata() {
+ const { name, kind, type } = this.schema['tj:source'];
+
+ if (!name || !kind || !type) {
+ throw new Error('Schema is missing required source metadata');
+ }
+
+ return {
+ name,
+ kind,
+ type,
+ options: this._getOptionsMetadata(),
+ // Can remove exposed variables?
+ exposedVariables: {
+ isLoading: false,
+ data: {},
+ rawData: {},
+ },
+ };
+ }
+
+ _convertDataSourceOptionsToData(options) {
+ return Object.entries(options).reduce((result, [key, { value }]) => {
+ // Skip empty string values
+ if (value !== '' && value !== null && value !== undefined) {
+ result[key] = value;
+ }
+
+ // Add a dummy value to pass validation for encrypted keys
+ if (this.getEncryptedProperties().includes(key)) {
+ result[key] = 'REDACTED';
+ }
+ return result;
+ }, {});
+ }
+
+ _getOptionsMetadata() {
+ const options = {};
+ const properties = this.schema.properties || {};
+
+ for (const [key, value] of Object.entries(properties)) {
+ options[key] = { type: value.type };
+ if (value.encrypted) {
+ options[key].encrypted = true;
+ }
+ }
+
+ return options;
+ }
+}
diff --git a/frontend/src/_services/library-app.service.js b/frontend/src/_services/library-app.service.js
index ed8b464c71..815fa560c0 100644
--- a/frontend/src/_services/library-app.service.js
+++ b/frontend/src/_services/library-app.service.js
@@ -8,11 +8,11 @@ export const libraryAppService = {
findDependentPluginsInTemplate,
};
-function deploy(identifier, appName, dependentPluginsForTemplate = [], shouldAutoImportPlugin = false) {
+function deploy(identifier, appName, dependentPlugins = [], shouldAutoImportPlugin = false) {
const body = {
identifier,
appName,
- dependentPluginsForTemplate,
+ dependentPlugins,
shouldAutoImportPlugin,
};
diff --git a/frontend/src/_services/plugins.service.js b/frontend/src/_services/plugins.service.js
index 14437b1c97..e66cdd759c 100644
--- a/frontend/src/_services/plugins.service.js
+++ b/frontend/src/_services/plugins.service.js
@@ -1,4 +1,6 @@
import HttpClient from '@/_helpers/http-client';
+import config from 'config';
+import { authHeader, handleResponse } from '@/_helpers';
const adapter = new HttpClient();
@@ -22,10 +24,47 @@ function reloadPlugin(id) {
return adapter.post(`/plugins/${id}/reload`);
}
+function findDependentPlugins(dataSources) {
+ return adapter.post(`/plugins/findDependentPlugins`, dataSources);
+}
+
+function installDependentPlugins(dependentPlugins, shouldAutoImportPlugin) {
+ const body = {
+ dependentPlugins,
+ shouldAutoImportPlugin,
+ };
+
+ const requestOptions = {
+ method: 'POST',
+ headers: authHeader(),
+ credentials: 'include',
+ body: JSON.stringify(body),
+ };
+ return fetch(`${config.apiUrl}/plugins/installDependentPlugins`, requestOptions).then(handleResponse);
+}
+
+function uninstallPlugins(pluginsId) {
+ const body = {
+ pluginsId: pluginsId,
+ };
+
+ const requestOptions = {
+ method: 'POST',
+ headers: authHeader(),
+ credentials: 'include',
+ body: JSON.stringify(body),
+ };
+
+ return fetch(`${config.apiUrl}/plugins/uninstallPlugins`, requestOptions).then(handleResponse);
+}
+
export const pluginsService = {
findAll,
installPlugin,
updatePlugin,
deletePlugin,
reloadPlugin,
+ findDependentPlugins,
+ installDependentPlugins,
+ uninstallPlugins,
};
diff --git a/frontend/src/_ui/HttpHeaders/SourceEditor.jsx b/frontend/src/_ui/HttpHeaders/SourceEditor.jsx
index 959c25941c..04df343740 100644
--- a/frontend/src/_ui/HttpHeaders/SourceEditor.jsx
+++ b/frontend/src/_ui/HttpHeaders/SourceEditor.jsx
@@ -42,7 +42,7 @@ export default ({
disabled={isDisabled}
style={{
flex: 1,
- width: width ? width : '300px',
+ width: '316px',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
borderRight: 'none',
diff --git a/frontend/src/_ui/Input-V3/index.js b/frontend/src/_ui/Input-V3/index.js
new file mode 100644
index 0000000000..abc819c80a
--- /dev/null
+++ b/frontend/src/_ui/Input-V3/index.js
@@ -0,0 +1,85 @@
+import React, { useState } from 'react';
+import cx from 'classnames';
+import OrgConstantVariablesPreviewBox from '../../_components/OrgConstantsVariablesResolver';
+import SolidIcon from '../Icon/SolidIcons';
+import { toast } from 'react-hot-toast';
+import InputComponent from '@/components/ui/Input/Index';
+
+const InputV3 = ({ helpText, ...props }) => {
+ const { workspaceVariables, workspaceConstants, value, widget, disabled, encrypted } = props;
+ const [isFocused, setIsFocused] = useState(false);
+ const [isCopied, setIsCopied] = useState(false);
+
+ const handleCopyToClipboard = async () => {
+ if (widget === 'copyToClipboard') {
+ try {
+ await navigator.clipboard.writeText(value);
+ toast.success('Copied to clipboard');
+ setIsCopied(true);
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 4000);
+ } catch (err) {
+ console.error('Failed to copy text: ', err);
+ }
+ }
+ };
+
+ return (
+
+
+ {widget === 'text-v3' && (
+
+ )}
+ {(widget === 'password-v3' || encrypted) && (
+
+
+
+ )}
+ {widget === 'copyToClipboard' &&
+ value &&
+ (!isCopied ? (
+
+ {' '}
+
+
+ ) : (
+
+ {' '}
+ Copied!
+
+ ))}
+
+
+
+ {helpText &&
}
+
+ );
+};
+
+export default InputV3;
diff --git a/frontend/src/components/ui/Input/CommonInput/Index.jsx b/frontend/src/components/ui/Input/CommonInput/Index.jsx
index ba1e02df91..0710876ac3 100644
--- a/frontend/src/components/ui/Input/CommonInput/Index.jsx
+++ b/frontend/src/components/ui/Input/CommonInput/Index.jsx
@@ -1,28 +1,79 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import NumberInput from './NumberInput';
import TextInput from './TextInput';
import { HelperMessage, InputLabel, ValidationMessage } from '../InputUtils/InputUtils';
+import { ButtonSolid } from '../../../../_components/AppButton';
-const CommonInput = ({ label, helperText, disabled, required, ...restProps }) => {
- const InputComponentType = restProps.type === 'number' ? NumberInput : TextInput;
+const CommonInput = ({ label, helperText, disabled, required, onChange: change, ...restProps }) => {
+ const { type, encrypted, validation, isValidatedMessages, isDisabled } = restProps;
+
+ const InputComponentType = type === 'number' ? NumberInput : TextInput;
const [isValid, setIsValid] = useState(null);
const [message, setMessage] = useState('');
+ const [isEditing, setIsEditing] = useState(false);
+
+ const isEncrypted = type === 'password' || encrypted;
const handleChange = (e) => {
- let validateObj;
- if (restProps.validation) {
- validateObj = restProps.validation(e);
+ if (validation) {
+ const validateObj = validation(e);
setIsValid(validateObj.valid);
setMessage(validateObj.message);
+ change(e, validateObj);
+ } else {
+ change(e);
+ }
+ };
+
+ useEffect(() => {
+ if (isValidatedMessages) {
+ setIsValid(isValidatedMessages.valid);
+ setMessage(isValidatedMessages.message);
+ }
+ }, [isValidatedMessages]);
+
+ const toggleEditing = () => {
+ if (isDisabled) return;
+
+ const willBeInEditMode = !isEditing;
+ setIsEditing(willBeInEditMode);
+
+ if (willBeInEditMode) {
+ change({ target: { value: '' } });
}
- restProps.onChange(e, validateObj);
};
return (
- {label &&
}
+
+ {label &&
}
+ {type === 'password' && (
+
+
+
+ {isEditing ? 'Cancel' : 'Edit'}
+
+
+
+
+
+
+ Encrypted
+
+
+
+ )}
+
const inputStyle = `tw-border-border-default placeholder:tw-text-text-placeholder tw-font-normal disabled:tw-bg-[#CCD1D5]/30 tw-pr-[12px] ${
leadingIcon ? (size === 'small' ? 'tw-pl-[32px]' : 'tw-pl-[34px]') : 'tw-pl-[12px]'
} ${
- response === true ? 'tw-border-border-success-strong' : response === false ? 'tw-border-border-danger-strong' : ''
+ response === true
+ ? 'tw-border-border-success-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-success-strong'
+ : response === false
+ ? 'tw-border-border-danger-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-success-strong'
+ : ''
}`;
const handleIncrement = () => {
diff --git a/frontend/src/components/ui/Input/CommonInput/TextInput.jsx b/frontend/src/components/ui/Input/CommonInput/TextInput.jsx
index e911193b63..1834d4799d 100644
--- a/frontend/src/components/ui/Input/CommonInput/TextInput.jsx
+++ b/frontend/src/components/ui/Input/CommonInput/TextInput.jsx
@@ -17,7 +17,11 @@ const TextInput = ({
const inputStyle = `tw-border-border-default placeholder:tw-text-text-placeholder tw-font-normal disabled:tw-bg-[#CCD1D5]/30 ${
leadingIcon ? (size === 'small' ? 'tw-pl-[32px]' : 'tw-pl-[34px]') : 'tw-pl-[12px]'
} ${trailingAction ? (size === 'small' ? 'tw-pr-[40px]' : 'tw-pr-[44px]') : 'tw-pr-[12px]'} ${
- response === true ? '!tw-border-border-success-strong' : response === false ? '!tw-border-border-danger-strong' : ''
+ response === true
+ ? '!tw-border-border-success-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-success-strong'
+ : response === false
+ ? '!tw-border-border-danger-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-danger-strong'
+ : ''
}`;
return (
diff --git a/frontend/src/components/ui/Input/Index.jsx b/frontend/src/components/ui/Input/Index.jsx
index 3e8b879029..139019a198 100644
--- a/frontend/src/components/ui/Input/Index.jsx
+++ b/frontend/src/components/ui/Input/Index.jsx
@@ -38,7 +38,7 @@ InputComponent.defaultProps = {
size: 'medium',
disabled: false,
readOnly: '',
- validation: (e) => {},
+ validation: null,
label: '',
'aria-label': '',
required: false,
diff --git a/frontend/src/components/ui/Input/Input.jsx b/frontend/src/components/ui/Input/Input.jsx
index 8ee22a8f42..33c3194f0b 100644
--- a/frontend/src/components/ui/Input/Input.jsx
+++ b/frontend/src/components/ui/Input/Input.jsx
@@ -1,18 +1,40 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { inputVariants } from './InputUtils/Variants';
+import SolidIcon from '../../../_ui/Icon/SolidIcons';
+
+const Input = React.forwardRef(({ className, size, type, ...props }, ref) => {
+ const [isPasswordVisible, setIsPasswordVisible] = React.useState(false);
+ const isPasswordField = type === 'password';
+
+ const togglePasswordVisibility = () => {
+ if (!props.disabled) {
+ setIsPasswordVisible((prev) => !prev);
+ }
+ };
-const Input = React.forwardRef(({ className, size, ...props }, ref) => {
return (
-
+
+ {isPasswordField && (
+
+ {isPasswordVisible ? (
+
+ ) : (
+
+ )}
+
)}
- ref={ref}
- {...props}
- />
+ >
);
});
Input.displayName = 'Input';
diff --git a/frontend/src/components/ui/Input/InputUtils/InputUtils.jsx b/frontend/src/components/ui/Input/InputUtils/InputUtils.jsx
index 5e09cec4fa..57128adf73 100644
--- a/frontend/src/components/ui/Input/InputUtils/InputUtils.jsx
+++ b/frontend/src/components/ui/Input/InputUtils/InputUtils.jsx
@@ -13,7 +13,7 @@ export const ValidationMessage = ({ response, validationMessage, className }) =>
htmlFor="validation"
type="helper"
size="default"
- className={`tw-font-normal ${response === true ? 'tw-text-text-success' : 'tw-text-text-warning'}`}
+ className={`tw-font-normal ${response === true ? 'tw-text-text-success' : '!tw-text-text-warning'}`}
data-cy="validation-label"
>
{validationMessage}
diff --git a/frontend/src/modules/common/components/DataSourceComponents/index.js b/frontend/src/modules/common/components/DataSourceComponents/index.js
index e783da7021..386db12b00 100644
--- a/frontend/src/modules/common/components/DataSourceComponents/index.js
+++ b/frontend/src/modules/common/components/DataSourceComponents/index.js
@@ -1,5 +1,6 @@
import React from 'react';
import DynamicForm from '@/_components/DynamicForm';
+import DynamicFormV2 from '@/_components/DynamicFormV2';
import RunjsSchema from './Runjs.schema.json';
import TooljetDbSchema from '@/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/manifest.json';
import RunpySchema from './Runpy.schema.json';
@@ -7,12 +8,37 @@ import WorkflowsSchema from './Workflows.schema.json';
// eslint-disable-next-line import/no-unresolved
import { allManifests } from '@tooljet/plugins/client';
+import DataSourceSchemaManager from '@/_helpers/dataSourceSchemaManager';
+
+const getSchemaDetailsForRender = (schema) => {
+ if (schema['tj:version']) {
+ const dsm = new DataSourceSchemaManager(schema);
+ const initialSourceValues = dsm.getDefaults();
+ return {
+ name: schema['tj:source'].name,
+ kind: schema['tj:source'].kind,
+ type: schema['tj:source'].type,
+ options: initialSourceValues,
+ };
+ }
+
+ const _source = schema.source;
+ const def = schema.defaults ?? {};
+
+ return { ..._source, defaults: def };
+};
+
+const getSchemaMetadata = (schema, key) => {
+ if (schema['tj:version']) return schema['tj:source'][key];
+ // Need to depreciate old schema format
+ if (key === 'type') return schema.type;
+ return schema.source[key];
+};
//Commonly Used DS
-
export const CommonlyUsedDataSources = Object.keys(allManifests)
.reduce((accumulator, currentValue) => {
- const sourceName = allManifests[currentValue]?.source?.name;
+ const sourceName = getSchemaMetadata(allManifests[currentValue], 'name');
if (
sourceName === 'REST API' ||
sourceName === 'MongoDB' ||
@@ -20,9 +46,7 @@ export const CommonlyUsedDataSources = Object.keys(allManifests)
sourceName === 'Google Sheets' ||
sourceName === 'PostgreSQL'
) {
- const _source = allManifests[currentValue].source;
- const def = allManifests[currentValue]?.defaults ?? {};
- accumulator.push({ ..._source, defaults: def });
+ accumulator.push(getSchemaDetailsForRender(allManifests[currentValue]));
}
return accumulator;
@@ -33,31 +57,23 @@ export const CommonlyUsedDataSources = Object.keys(allManifests)
});
export const DataBaseSources = Object.keys(allManifests).reduce((accumulator, currentValue) => {
- if (allManifests[currentValue].type === 'database') {
- const _source = allManifests[currentValue].source;
- const def = allManifests[currentValue]?.defaults ?? {};
-
- accumulator.push({ ..._source, defaults: def });
+ if (getSchemaMetadata(allManifests[currentValue], 'type') === 'database') {
+ accumulator.push(getSchemaDetailsForRender(allManifests[currentValue]));
}
return accumulator;
}, []);
-export const ApiSources = Object.keys(allManifests).reduce((accumulator, currentValue) => {
- if (allManifests[currentValue].type === 'api') {
- const _source = allManifests[currentValue].source;
- const def = allManifests[currentValue]?.defaults ?? {};
- accumulator.push({ ..._source, defaults: def });
+export const ApiSources = Object.keys(allManifests).reduce((accumulator, currentValue) => {
+ if (getSchemaMetadata(allManifests[currentValue], 'type') === 'api') {
+ accumulator.push(getSchemaDetailsForRender(allManifests[currentValue]));
}
return accumulator;
}, []);
export const CloudStorageSources = Object.keys(allManifests).reduce((accumulator, currentValue) => {
- if (allManifests[currentValue].type === 'cloud-storage') {
- const _source = allManifests[currentValue].source;
- const def = allManifests[currentValue]?.defaults ?? {};
-
- accumulator.push({ ..._source, defaults: def });
+ if (getSchemaMetadata(allManifests[currentValue], 'type') === 'cloud-storage') {
+ accumulator.push(getSchemaDetailsForRender(allManifests[currentValue]));
}
return accumulator;
@@ -73,8 +89,24 @@ export const DataSourceTypes = [
];
export const SourceComponents = Object.keys(allManifests).reduce((accumulator, currentValue) => {
- accumulator[currentValue] = (props) => ;
+ accumulator[currentValue] = (props) => {
+ const schema = allManifests[currentValue];
+
+ if (schema['tj:version']) {
+ return ;
+ }
+
+ return ;
+ };
return accumulator;
}, {});
-export const SourceComponent = (props) => ;
+export const SourceComponent = (props) => {
+ const schema = props.dataSourceSchema;
+
+ if (schema['tj:version']) {
+ return ;
+ }
+
+ return ;
+};
diff --git a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx
index 4447ef19d2..dd19801dfd 100644
--- a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx
+++ b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx
@@ -34,6 +34,7 @@ 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';
class DataSourceManagerComponent extends React.Component {
@@ -81,6 +82,8 @@ class DataSourceManagerComponent extends React.Component {
unsavedChangesModal: false,
datasourceName,
creatingApp: false,
+ validationError: [],
+ validationMessages: {},
};
}
@@ -208,8 +211,31 @@ class DataSourceManagerComponent extends React.Component {
};
createDataSource = () => {
- const { appId, options, selectedDataSource, selectedDataSourcePluginId, dataSourceMeta, dataSourceSchema } =
- this.state;
+ const {
+ appId,
+ options,
+ selectedDataSource,
+ selectedDataSourcePluginId,
+ dataSourceMeta,
+ dataSourceSchema,
+ validationMessages,
+ } = this.state;
+
+ if (!isEmpty(validationMessages)) {
+ const validationMessageArray = Object.values(validationMessages);
+ this.setState({ validationError: validationMessageArray });
+ 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'];
const name = selectedDataSource.name;
const kind = selectedDataSource?.kind;
@@ -231,6 +257,7 @@ class DataSourceManagerComponent extends React.Component {
const value = localStorage.getItem('OAuthCode');
parsedOptions.push({ key: 'code', value, encrypted: false });
}
+
if (name.trim() !== '') {
let service = scope === 'global' ? globalDatasourceService : datasourceService;
if (selectedDataSource.id) {
@@ -335,6 +362,25 @@ class DataSourceManagerComponent extends React.Component {
this.setState({ suggestingDatasources: true, activeDatasourceList: '#' });
};
+ setValidationMessages = (errors, schema) => {
+ 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 });
+ };
+
renderSourceComponent = (kind, isPlugin = false) => {
const { options, isSaving } = this.state;
@@ -352,6 +398,9 @@ class DataSourceManagerComponent extends React.Component {
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}
/>
);
@@ -851,6 +900,7 @@ class DataSourceManagerComponent extends React.Component {
dataSourceConfirmModalProps,
addingDataSource,
datasourceName,
+ validationError,
} = this.state;
const isPlugin = dataSourceSchema ? true : false;
const createSelectedDataSource = (dataSource) => {
@@ -1056,6 +1106,18 @@ class DataSourceManagerComponent extends React.Component {
)}
+ {validationError && validationError.length > 0 && (
+
+
+ {validationError.map((error, index) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+
- {containerRef && containerRef?.current && (
+ {containerRef && containerRef?.current && selectedDataSource && (
-
-