Merge pull request #12401 from ToolJet/release/marketplace-sprint-9

Release: Marketplace Sprint 9

🚀 Features
Auto install plugin based on queries on app import in #12350 by @ganesh8056
Azure Repos plugin integration in #11707 by @kushalsourav
NocoDB plugin integration in #11476 by @wusuopu

🌟 Enhancements
Added dynamic form validation setup for Postgres in #12292 by @parthy007
BigQuery now shows detailed errors and has documentation link in #12384 by @thesynthax
DTO validation for data types in ToolJet database in #123678 by @ganesh8056

🛠️ Fixes
Fixes TJDB error message text not having proper column name on bulk upload in #12346 by @manishkushare
The data in the time field of the calendar is not visible when scrolling vertically in #12352 by @manishkushare
Fixes unnecessary API calls on data source config page in #12368 by @ganesh8056
Exporting an application for a selected version does not export the ToolJet database schema in #12368 by @ganesh8056
This commit is contained in:
Akshay 2025-04-04 17:37:47 +05:30 committed by GitHub
commit 0941ba4571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1488 additions and 320 deletions

View file

@ -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": [],

View file

@ -1 +1 @@
3.7.0
3.8.0

View file

@ -1 +1 @@
3.7.0
3.8.0

@ -1 +1 @@
Subproject commit 715a830c7a8d75efc7f77106292d9e4499005b69
Subproject commit 4b950ed3d0ba15edddf217936e9c9ae1ca3cf11a

View file

@ -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;
}
}
}

View file

@ -300,7 +300,7 @@ export const DateTimePicker = ({
return (
<div
data-disabled={styles.disabledState}
className={cx('datepicker-widget', {
className={cx('datepicker-widget position-relative', {
'theme-tjdb': !darkMode,
'theme-dark': darkMode,
})}
@ -324,7 +324,7 @@ export const DateTimePicker = ({
})}
popperPlacement={'bottom-start'}
popperClassName={cx({
'tjdb-datepicker-reset': !isEditCell,
// 'tjdb-datepicker-reset': !isEditCell,
'tjdb-datepicker-celledit-reset': isEditCell,
})}
onInputClick={() => {

View file

@ -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;
}
}
}
}
.table-schema-row{
.react-datepicker-time__input-container{
input{
line-height: normal !important;
}
}
}

View file

@ -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 }));

View file

@ -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 (

View file

@ -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({
</div>
)}
</div>
{dependentPluginsForTemplate && dependentPluginsForTemplate.length >= 1 && (
{dependentPlugins && dependentPlugins.length >= 1 && (
<div onClick={(e) => e.stopPropagation()}>
<PluginsListForAppModal
dependentPluginsForTemplate={dependentPluginsForTemplate}
dependentPlugins={dependentPlugins}
dependentPluginsDetail={dependentPluginsDetail}
/>
</div>

View file

@ -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 <div>Type is invalid</div>;
}
};
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 = (
<label
className="form-label"
data-cy={`label-${String(label).toLowerCase().replace(/\s+/g, '-')}`}
style={{ textDecoration: tooltip ? 'underline 2px dashed' : 'none', textDecorationColor: 'var(--slate8)' }}
>
{label}
</label>
);
if (tooltip) {
return (
<OverlayTrigger
placement="top"
trigger="click"
rootClose
overlay={<Tooltip id={`tooltip-${label}`}>{tooltip}</Tooltip>}
>
{labelElement}
</OverlayTrigger>
);
}
return labelElement;
};
return (
<div className={`${isHorizontalLayout ? '' : 'row'}`}>
{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 (
<div
className={cx('my-2', {
'col-md-12': !className && !isHorizontalLayout,
[className]: !!className,
'd-flex': isHorizontalLayout,
'dynamic-form-row': isHorizontalLayout,
})}
key={key}
>
{!isSpecificComponent && (
<div
className={cx('d-flex', {
'form-label': isHorizontalLayout,
'align-items-center': !isHorizontalLayout,
})}
style={{ minWidth: '100px' }}
>
{label &&
widget !== 'text-v3' &&
widget !== 'password-v3' &&
renderLabel(label, uiProperties[key].tooltip)}
</div>
)}
<div
className={cx(
{
'flex-grow-1': isHorizontalLayout && !isSpecificComponent,
'w-100': isHorizontalLayout && widget !== 'codehinter',
},
'dynamic-form-element'
)}
style={{ width: '100%' }}
>
<Element
{...getElementProps(uiProperties[key])}
{...computedProps[propertyKey]}
data-cy={`${String(label).toLocaleLowerCase().replace(/\s+/g, '-')}-text-field`}
//to be removed after whole ui is same
isHorizontalLayout={isHorizontalLayout}
/>
</div>
</div>
);
})}
</div>
);
};
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 (
<div key={flipComponentDropdown.key}>
<div className={isHorizontalLayout ? '' : 'row'}>
{flipComponentDropdown.commonFields && getLayout(flipComponentDropdown.commonFields)}
<div
className={cx('my-2', {
'col-md-12': !flipComponentDropdown.className && !isHorizontalLayout,
'd-flex': isHorizontalLayout,
'dynamic-form-row': isHorizontalLayout,
[flipComponentDropdown.className]: !!flipComponentDropdown.className,
})}
>
{(flipComponentDropdown.label || isHorizontalLayout) && (
<label
className={cx('form-label')}
data-cy={`${String(flipComponentDropdown.label)
.toLocaleLowerCase()
.replace(/\s+/g, '-')}-dropdown-label`}
>
{flipComponentDropdown.label}
</label>
)}
<div data-cy={'query-select-dropdown'} className={cx({ 'flex-grow-1': isHorizontalLayout })}>
<Select {...getElementProps(flipComponentDropdown)} styles={{}} useCustomStyles={false} />
</div>
{flipComponentDropdown.helpText && (
<span className="flip-dropdown-help-text">{flipComponentDropdown.helpText}</span>
)}
</div>
</div>
{getLayout(uiProperties[selector])}
</div>
);
});
const normalComponents = Object.keys(uiProperties).map((key) => {
const component = uiProperties[key];
if (component.type && component.type !== 'dropdown-component-flip') {
return <div key={key}>{getLayout({ [key]: component })}</div>;
}
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;

View file

@ -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
)}
</span>
{isExpanded && dependentPluginsForTemplate && dependentPluginsForTemplate.length > 0 && (
{isExpanded && dependentPlugins && dependentPlugins.length > 0 && (
<div
style={{
minHeight: '20px',
@ -38,7 +38,7 @@ export const PluginsListForAppModal = ({ dependentPluginsForTemplate, dependentP
borderLeft: '1px solid var(--border-weak)',
}}
>
{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 (

View file

@ -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;
}
}

View file

@ -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,
};

View file

@ -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,
};

View file

@ -42,7 +42,7 @@ export default ({
disabled={isDisabled}
style={{
flex: 1,
width: width ? width : '300px',
width: '316px',
borderTopRightRadius: '0',
borderBottomRightRadius: '0',
borderRight: 'none',

View file

@ -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 (
<div className="tj-app-input">
<div
className={cx('', {
'tj-app-input-wrapper': widget === 'password' || widget === 'copyToClipboard' || encrypted,
})}
style={{ alignItems: 'flex-start' }}
>
{widget === 'text-v3' && (
<InputComponent
{...props}
value={value}
styles="tw-bg-transparent"
label={props.label}
placeholder={props.placeholder}
required={props.isRequired}
/>
)}
{(widget === 'password-v3' || encrypted) && (
<div style={{ flex: '1' }}>
<InputComponent
{...props}
type="password"
value={value}
styles="tw-bg-transparent"
label={props.label}
placeholder={props.placeholder}
required={props.isRequired}
/>
</div>
)}
{widget === 'copyToClipboard' &&
value &&
(!isCopied ? (
<div style={{ cursor: 'pointer' }} onClick={handleCopyToClipboard}>
{' '}
<SolidIcon className="copy-icon" name="copy" />
</div>
) : (
<div style={{ color: 'green' }}>
{' '}
<span>Copied!</span>
</div>
))}
</div>
<OrgConstantVariablesPreviewBox
workspaceVariables={workspaceVariables}
workspaceConstants={workspaceConstants}
isFocused={isFocused}
value={value}
/>
{helpText && <small className="text-muted" dangerouslySetInnerHTML={{ __html: helpText }} />}
</div>
);
};
export default InputV3;

View file

@ -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 (
<div>
{label && <InputLabel disabled={disabled} label={label} required={required} />}
<div className="d-flex">
{label && <InputLabel disabled={disabled} label={label} required={required} />}
{type === 'password' && (
<div className="d-flex justify-content-between w-100">
<div className="mx-1 col">
<ButtonSolid
className="datasource-edit-btn mb-2"
type="a"
variant="tertiary"
target="_blank"
rel="noreferrer"
disabled={isDisabled}
onClick={toggleEditing}
>
{isEditing ? 'Cancel' : 'Edit'}
</ButtonSolid>
</div>
<div className="col-auto mb-2">
<small className="text-green">
<img className="mx-2 encrypted-icon" src="assets/images/icons/padlock.svg" width="12" height="12" />
Encrypted
</small>
</div>
</div>
)}
</div>
<InputComponentType
disabled={disabled}
disabled={disabled || (isEncrypted && !isEditing)}
required={required}
response={isValid}
onChange={handleChange}

View file

@ -24,7 +24,11 @@ const NumberInput = ({ size, leadingIcon, response, disabled, ...restProps }) =>
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 = () => {

View file

@ -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 (

View file

@ -38,7 +38,7 @@ InputComponent.defaultProps = {
size: 'medium',
disabled: false,
readOnly: '',
validation: (e) => {},
validation: null,
label: '',
'aria-label': '',
required: false,

View file

@ -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 (
<input
className={cn(
inputVariants({ size }),
`tw-peer tw-flex tw-text-[12px]/[18px] tw-w-full tw-rounded-[8px] tw-border-[1px] tw-border-solid tw-bg-background-surface-layer-01 tw-py-[7px] tw-text-text-default focus-visible:tw-ring-[1px] focus-visible:tw-ring-offset-[1px] focus-visible:tw-ring-border-accent-strong focus-visible:tw-ring-offset-border-accent-strong focus-visible:tw-border-transparent disabled:tw-cursor-not-allowed disabled:tw-border-transparent`,
className
<>
<input
type={isPasswordField && isPasswordVisible ? 'text' : type}
className={cn(
inputVariants({ size }),
`tw-relative tw-peer tw-flex tw-text-[12px]/[18px] tw-w-full tw-rounded-[8px] tw-border-[1px] tw-border-solid tw-bg-background-surface-layer-01 tw-py-[7px] tw-text-text-default focus-visible:tw-ring-[1px] focus-visible:tw-ring-offset-[1px] focus-visible:tw-ring-border-accent-strong focus-visible:tw-ring-offset-border-accent-strong focus-visible:tw-border-transparent disabled:tw-cursor-not-allowed ${props.styles}`,
className
)}
ref={ref}
{...props}
/>
{isPasswordField && (
<div onClick={togglePasswordVisibility}>
{isPasswordVisible ? (
<SolidIcon className="eye-icon" name="eye" />
) : (
<SolidIcon className="eye-icon" name="eyedisable" />
)}
</div>
)}
ref={ref}
{...props}
/>
</>
);
});
Input.displayName = 'Input';

View file

@ -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}

View file

@ -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) => <DynamicForm schema={allManifests[currentValue]} isGDS={true} {...props} />;
accumulator[currentValue] = (props) => {
const schema = allManifests[currentValue];
if (schema['tj:version']) {
return <DynamicFormV2 schema={schema} isGDS={true} {...props} />;
}
return <DynamicForm schema={schema} isGDS={true} {...props} />;
};
return accumulator;
}, {});
export const SourceComponent = (props) => <DynamicForm schema={props.dataSourceSchema} isGDS={true} {...props} />;
export const SourceComponent = (props) => {
const schema = props.dataSourceSchema;
if (schema['tj:version']) {
return <DynamicFormV2 schema={schema} isGDS={true} {...props} />;
}
return <DynamicForm schema={schema} isGDS={true} {...props} />;
};

View file

@ -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 {
</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="connection-alert-text">
{error}
</div>
))}
</div>
</div>
)}
<div className="col">
<SolidIcon name="logs" fill="#3E63DD" width="20" style={{ marginRight: '8px' }} />
<a

View file

@ -474,7 +474,7 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
<div className="row gx-0">
<Sidebar renderSidebarList={renderSidebarList} updateSelectedDatasource={updateSelectedDatasource} />
<div ref={containerRef} className={cx('col animation-fade datasource-modal-container', {})}>
{containerRef && containerRef?.current && (
{containerRef && containerRef?.current && selectedDataSource && (
<DataSourceManager
showBackButton={selectedDataSource ? false : true}
showDataSourceManagerModal={showDataSourceManagerModal}

View file

@ -1,72 +1,15 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<g>
<rect x="445.176" y="222.066" width="15.208" height="32.606"/>
<path d="M465.275,103.536V55.237c0-10.89-8.859-19.75-19.749-19.75H292.721c-10.89,0-19.75,8.86-19.75,19.75v48.298h-38.498
V55.237c0-10.89-8.86-19.75-19.75-19.75H61.918c-10.891,0-19.75,8.86-19.75,19.75v48.298H0v372.977h512V103.536H465.275z
M288.178,55.237c0-2.505,2.038-4.542,4.542-4.542h152.805c2.504,0,4.541,2.038,4.541,4.542v48.298H422.47V64.268h-15.208v39.268
H288.178V55.237z M57.374,55.237c0-2.505,2.038-4.542,4.542-4.542h152.804c2.504,0,4.542,2.038,4.542,4.542v48.298h-29.126
V64.268h-15.208v39.268H57.374V55.237z M496.792,461.305h-36.404V269.298H445.18v192.007H15.208V118.743h26.959h192.305h38.498
h192.305h31.517V461.305z"/>
<path d="M385.777,275.757c-1.028-0.078-2.067-0.118-3.092-0.118c-10.329,0-20.117,3.942-27.562,11.096
c-0.054,0.051-0.105,0.101-0.159,0.15v-81.028h-81.027c0.05-0.054,0.099-0.105,0.149-0.158
c7.838-8.156,11.84-19.33,10.978-30.656c-1.449-19.026-16.24-34.353-35.171-36.443c-1.494-0.164-3.008-0.248-4.5-0.248
c-21.939,0-39.787,17.848-39.787,39.788c0,10.414,3.994,20.254,11.25,27.719h-81.031v99.334h7.604
c6.248,0,12.237-2.492,16.431-6.835c5.122-5.303,12.213-8.006,19.621-7.435c11.548,0.878,21.213,10.196,22.481,21.673
c0.787,7.121-1.381,13.948-6.105,19.223c-4.661,5.204-11.337,8.189-18.317,8.189c-6.631,0-12.846-2.602-17.502-7.328
c-3.186-3.232-7.177-5.463-11.542-6.449c-0.757-0.172-1.534-0.259-2.307-0.259c-5.715,0-10.364,4.639-10.364,10.343v88.681
h88.991c5.703,0,10.343-4.639,10.343-10.342c0-6.248-2.492-12.238-6.836-16.432c-5.285-5.103-7.994-12.256-7.435-19.622
c0.879-11.548,10.197-21.212,21.675-22.48c0.943-0.105,1.895-0.157,2.831-0.157c13.553,0,24.58,11.027,24.58,24.58
c0,6.606-2.585,12.805-7.281,17.456c-4.555,4.514-7.065,10.396-7.065,16.565c0,5.753,4.68,10.433,10.434,10.433h88.9v-81.031
c7.465,7.255,17.304,11.25,27.719,11.25c11.299,0,22.105-4.83,29.646-13.251c7.537-8.415,11.142-19.728,9.893-31.037
C420.13,291.995,404.803,277.205,385.777,275.757z M401.001,331.817c-4.661,5.205-11.338,8.19-18.317,8.19
c-6.457,0-12.557-2.486-17.174-7c-4.843-4.737-11.287-7.344-18.148-7.344h-7.604v84.126h-67.227
c0.262-0.342,0.551-0.671,0.868-0.987c7.6-7.528,11.785-17.564,11.785-28.259c0-21.94-17.848-39.788-39.788-39.788
c-1.491,0-3.005,0.084-4.5,0.248c-18.931,2.093-33.722,17.419-35.17,36.442c-0.905,11.898,3.482,23.458,12.036,31.718
c0.202,0.195,0.391,0.404,0.569,0.625h-67.295v-64.691c7.3,6.542,16.618,10.117,26.507,10.117
c11.298,0,22.104-4.83,29.646-13.251c7.537-8.415,11.142-19.728,9.892-31.038c-2.092-18.93-17.419-33.72-36.444-35.168
c-1.027-0.078-2.068-0.118-3.092-0.118c-9.906,0-19.212,3.571-26.508,10.116v-64.689l84.126-0.005v-7.604
c0-6.856-2.608-13.299-7.344-18.144c-4.515-4.618-7.001-10.716-7.001-17.174c0-13.553,11.026-24.58,24.579-24.58
c0.936,0,1.888,0.053,2.831,0.157c11.478,1.267,20.796,10.932,21.675,22.481c0.541,7.117-1.867,13.851-6.78,18.964
c-4.831,5.026-7.491,11.525-7.491,18.3v7.604h84.126v84.126h7.604c6.775,0,13.273-2.66,18.301-7.491
c5.061-4.863,11.901-7.314,18.963-6.78c11.548,0.878,21.213,10.196,22.481,21.675
C407.893,319.717,405.725,326.542,401.001,331.817z"/>
</g>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_519_49)">
<path d="M16.9116 2.39346H7.08579C6.70857 2.39346 6.4032 2.70255 6.4032 3.08759V3.09021V5.3743H17.5967V3.09021C17.5967 2.70516 17.2939 2.39346 16.9167 2.39346C16.9141 2.39346 16.9141 2.39346 16.9116 2.39346Z" fill="#BC3618"/>
<path d="M18.0689 3.78959H5.93102C5.387 3.78959 4.94305 4.24012 4.94305 4.79542V4.79804V5.59171H19.0594V4.79804C19.0594 4.24274 18.6181 3.78959 18.074 3.78959C18.0715 3.78959 18.0715 3.78959 18.0689 3.78959Z" fill="#D13816"/>
<path d="M19.4932 5.21976H4.50686C3.96283 5.21976 3.51889 5.6703 3.51889 6.2256V6.22822V20.5929C3.51889 21.1508 3.96283 21.6039 4.51199 21.6066H19.488C20.0346 21.6066 20.4786 21.1534 20.4811 20.5929V6.22822C20.4811 5.67292 20.0398 5.22238 19.4932 5.21976Z" fill="#E15A18"/>
<path d="M8.5229 6.6997C7.48361 6.6997 6.63935 7.56147 6.64191 8.62232C6.64448 9.68316 7.48618 10.5449 8.52547 10.5423C9.56476 10.5423 10.4065 9.68054 10.4065 8.6197C10.409 7.56147 9.56476 6.6997 8.5229 6.6997ZM8.5229 9.86652C7.848 9.86652 7.29885 9.30859 7.29885 8.61708C7.29885 7.92818 7.84544 7.36764 8.5229 7.36764C9.1978 7.36764 9.74695 7.92556 9.74695 8.61708C9.74695 9.30859 9.20036 9.86652 8.5229 9.86652C8.52547 9.86652 8.52547 9.86652 8.5229 9.86652Z" fill="#F7B192"/>
<path d="M9.2311 10.3354H7.83511V14.233H9.2311V10.3354Z" fill="#F7B192"/>
<path d="M17.4685 8.64852C17.4685 7.58767 16.6242 6.7259 15.5849 6.7259C14.5456 6.7259 13.7014 7.58767 13.7014 8.64852C13.7014 9.44481 14.1812 10.1573 14.9075 10.4428V11.7629L7.83516 14.1466H7.82233V16.5905C6.86002 16.9886 6.39812 18.1071 6.78817 19.0893C7.17823 20.0716 8.27397 20.5431 9.23628 20.1449C10.1986 19.7468 10.6605 18.6283 10.2704 17.6461C10.0831 17.172 9.71615 16.7974 9.25681 16.5983V15.0843L16.2906 12.7112L16.2752 12.6823H16.2906V10.4245C17.0014 10.1363 17.4685 9.43171 17.4685 8.64852ZM9.74694 18.3795C9.74694 19.0684 9.20035 19.6289 8.52289 19.6289C7.84542 19.6289 7.29883 19.071 7.29883 18.3795C7.29883 17.6906 7.84542 17.13 8.52289 17.13C9.20035 17.1327 9.74694 17.6906 9.74694 18.3795ZM15.5849 9.89272C14.91 9.89272 14.3609 9.33479 14.3609 8.64328C14.3609 7.95438 14.9075 7.39384 15.5849 7.39384C16.2598 7.39384 16.809 7.95176 16.809 8.64328C16.809 9.33479 16.2598 9.89272 15.5849 9.89272Z" fill="#FCDACB"/>
</g>
<defs>
<clipPath id="clip0_519_49">
<rect width="18" height="20" fill="white" transform="translate(3 2)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -104,7 +104,37 @@ export default class Bigquery implements QueryService {
}
} catch (error) {
console.log(error);
throw new QueryError('Query could not be completed', error.message, {});
const errorMessage = error.message || "An unknown error occurred.";
let errorDetails: any = {};
const errorSuggestions = {
"notFound": "Check if the table or dataset exists in the specified location.",
"accessDenied": "Verify that the service account has the necessary permissions.",
"invalidQuery": "Check the SQL syntax and ensure that all referenced columns exist.",
"rateLimitExceeded": "You are making too many requests. Try again after some time.",
"backendError": "BigQuery encountered an internal error. Retry the request after some time.",
"quotaExceeded": "You have exceeded your quota limits. Consider upgrading your plan or reducing query size.",
"duplicate": "A resource with this name already exists. Try using a different name.",
"badRequest": "Check the request parameters and ensure they are correctly formatted.",
};
if (error && error instanceof Error) {
const bigqueryError = error as any;
errorDetails.error = bigqueryError;
const reason = bigqueryError.response?.status?.errorResult?.reason || "unknownError";
errorDetails.reason = reason;
errorDetails.message = errorMessage;
errorDetails.jobId = bigqueryError.response?.jobReference?.jobId;
errorDetails.location = bigqueryError.response?.jobReference?.location;
errorDetails.query = bigqueryError.response?.configuration?.query?.query;
const suggestion = errorSuggestions[reason];
errorDetails.suggestion = suggestion;
}
throw new QueryError('Query could not be completed', errorMessage, errorDetails);
}
return {

View file

@ -1,86 +1,179 @@
{
"$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/manifest.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Postgresql datasource",
"description": "A schema defining postgresql datasource",
"type": "database",
"source": {
"type": "object",
"tj:version": "1.0.0",
"tj:source": {
"name": "PostgreSQL",
"kind": "postgresql",
"options": {
"host": {
"type": "string"
},
"port": {
"type": "string"
},
"database": {
"type": "string"
},
"username": {
"type": "string"
},
"password": {
"type": "string",
"encrypted": true
},
"ca_cert": {
"encrypted": true
},
"client_key": {
"encrypted": true
},
"client_cert": {
"encrypted": true
},
"root_cert": {
"encrypted": true
},
"connection_options": {
"type": "array"
},
"connection_string": {
"type": "string",
"encrypted": true
}
},
"exposedVariables": {
"isLoading": false,
"data": {},
"rawData": {}
}
},
"defaults": {
"connection_type": {
"value": "manual"
},
"host": {
"value": "localhost"
},
"port": {
"value": 5432
},
"database": {
"value": ""
},
"username": {
"value": ""
},
"password": {
"value": ""
},
"ssl_enabled": {
"value": true
},
"ssl_certificate": {
"value": "none"
}
"type": "database"
},
"properties": {
"connection_type": {
"label": "Connection type",
"key": "connection_type",
"type": "dropdown-component-flip",
"type": "string",
"title": "Connection type",
"description": "Single select dropdown for connection_type",
"enum": [
"manual",
"string"
],
"default": "manual"
},
"host": {
"type": "string",
"title": "Host",
"description": "Enter host",
"default": "localhost"
},
"port": {
"type": "number",
"title": "Port",
"description": "Enter port",
"default": 5432
},
"database": {
"type": "string",
"title": "Database name",
"description": "Name of the database"
},
"username": {
"type": "string",
"title": "Username",
"description": "Enter username"
},
"password": {
"type": "string",
"title": "Password",
"description": "Enter password"
},
"ssl_enabled": {
"type": "boolean",
"title": "SSL",
"description": "Toggle for ssl_enabled",
"default": true
},
"ssl_certificate": {
"type": "string",
"title": "SSL certificate",
"description": "Single select dropdown for choosing certificates",
"enum": [
"ca_certificate",
"self_signed",
"none"
],
"default": "none"
},
"connection_string": {
"type": "string",
"title": "Connection string",
"description": "postgres://username:password@hostname:port/database?sslmode=require"
},
"ca_cert": {
"type": "string",
"title": "CA Cert",
"description": "Enter ca certificate"
},
"client_key": {
"type": "string",
"title": "Client Key",
"description": "Enter client key"
},
"client_cert": {
"type": "string",
"title": "Client Cert",
"description": "Enter client certificate"
},
"root_cert": {
"type": "string",
"title": "Root Cert",
"description": "Enter root certificate"
}
},
"tj:encrypted": [
"password",
"ca_cert",
"client_key",
"client_cert",
"root_cert",
"connection_string"
],
"required": [
"connection_type"
],
"allOf": [
{
"if": {
"properties": {
"connection_type": {
"const": "manual"
}
}
},
"then": {
"required": [
"host",
"port",
"username",
"password",
"ssl_certificate"
],
"allOf": [
{
"if": {
"properties": {
"ssl_certificate": {
"const": "ca_certificate"
}
}
},
"then": {
"required": [
"ca_cert"
]
}
},
{
"if": {
"properties": {
"ssl_certificate": {
"const": "self_signed"
}
}
},
"then": {
"required": [
"client_key",
"client_cert",
"root_cert"
]
}
}
]
}
},
{
"if": {
"properties": {
"connection_type": {
"const": "string"
}
}
},
"then": {
"required": [
"connection_string"
]
}
}
],
"tj:ui:properties": {
"connection_type": {
"$ref": "#/properties/connection_type",
"key": "connection_type",
"label": "Connection type",
"description": "Single select dropdown for connection_type",
"widget": "dropdown-component-flip",
"list": [
{
"name": "Manual connection",
@ -94,10 +187,11 @@
},
"manual": {
"ssl_certificate": {
"label": "SSL certificate",
"$ref": "#/properties/ssl_certificate",
"key": "ssl_certificate",
"type": "dropdown-component-flip",
"label": "SSL certificate",
"description": "Single select dropdown for choosing certificates",
"widget": "dropdown-component-flip",
"list": [
{
"value": "ca_certificate",
@ -114,97 +208,104 @@
],
"commonFields": {
"host": {
"label": "Host",
"$ref": "#/properties/host",
"key": "host",
"type": "text",
"description": "Enter host"
"label": "Host",
"description": "Enter host",
"widget": "text-v3",
"required": true
},
"port": {
"label": "Port",
"$ref": "#/properties/port",
"key": "port",
"type": "text",
"description": "Enter port"
"label": "Port",
"description": "Enter port",
"widget": "text-v3",
"required": true
},
"ssl_enabled": {
"label": "SSL",
"$ref": "#/properties/ssl_enabled",
"key": "ssl_enabled",
"type": "toggle",
"description": "Toggle for ssl_enabled"
"label": "SSL",
"description": "Toggle for ssl_enabled",
"widget": "toggle"
},
"database": {
"label": "Database name",
"$ref": "#/properties/database",
"key": "database",
"type": "text",
"description": "Name of the database"
"label": "Database name",
"description": "Name of the database",
"widget": "text-v3"
},
"username": {
"label": "Username",
"$ref": "#/properties/username",
"key": "username",
"type": "text",
"description": "Enter username"
"label": "Username",
"description": "Enter username",
"widget": "text-v3",
"required": true
},
"password": {
"label": "Password",
"$ref": "#/properties/password",
"key": "password",
"type": "password",
"description": "Enter password"
"label": "Password",
"description": "Enter password",
"widget": "password-v3",
"required": true
},
"connection_options": {
"label": "Connection options",
"$ref": "#/properties/connection_options",
"key": "connection_options",
"type": "react-component-headers",
"width":"316px"
"label": "Connection options",
"widget": "react-component-headers",
"width": "316px",
"required": true
}
}
},
"ca_certificate": {
"ca_cert": {
"label": "CA Cert",
"$ref": "#/properties/ca_cert",
"key": "ca_cert",
"type": "textarea",
"encrypted": true,
"description": "Enter ca certificate"
"label": "CA Cert",
"description": "Enter ca certificate",
"widget": "textarea"
}
},
"self_signed": {
"client_key": {
"label": "Client Key",
"$ref": "#/properties/client_key",
"key": "client_key",
"type": "textarea",
"encrypted": true,
"description": "Enter client key"
"label": "Client Key",
"description": "Enter client key",
"widget": "textarea"
},
"client_cert": {
"label": "Client Cert",
"$ref": "#/properties/client_cert",
"key": "client_cert",
"type": "textarea",
"encrypted": true,
"description": "Enter client certificate"
"label": "Client Cert",
"description": "Enter client certificate",
"widget": "textarea"
},
"root_cert": {
"label": "Root Cert",
"$ref": "#/properties/root_cert",
"key": "root_cert",
"type": "textarea",
"encrypted": true,
"description": "Enter root certificate"
"label": "Root Cert",
"description": "Enter root certificate",
"widget": "textarea",
"required": true
}
}
},
"string": {
"connection_string": {
"label": "Connection string",
"$ref": "#/properties/connection_string",
"key": "connection_string",
"type": "text",
"encrypted": true,
"description": "postgres://username:password@hostname:port/database?sslmode=require"
"label": "Connection string",
"description": "postgres://username:password@hostname:port/database?sslmode=require",
"widget": "text",
"required": true
}
}
},
"required": [
"host",
"port",
"username",
"database",
"password"
]
}
}

View file

@ -1 +1 @@
3.7.0
3.8.0

@ -1 +1 @@
Subproject commit 0eefbb71a1d5288f49641af5efaaab25970f27d1
Subproject commit 683647f83d3efeeadbe69c40b8e8dd5ba4e8ea06

View file

@ -193,5 +193,13 @@
"author": "Tooljet",
"timestamp": "Tue, 21 Jan 2025 16:55:28 GMT",
"tags": ["AI"]
},
{
"name": "azurerepos",
"description": "api plugin from azurerepos",
"version": "1.0.0",
"id": "azurerepos",
"author": "Tooljet",
"timestamp": "Mon, 23 Dec 2024 11:57:30 GMT"
}
]

View file

@ -15,10 +15,20 @@ export class FeatureAbilityFactory extends AbilityFactory<FEATURE_KEY, Subjects>
}
protected defineAbilityFor(can: AbilityBuilder<FeatureAbility>['can'], UserAllPermissions: UserAllPermissions): void {
const { superAdmin, isAdmin } = UserAllPermissions;
if (superAdmin || isAdmin) {
// Admin or super admin and do all operations
can([FEATURE_KEY.INSTALL, FEATURE_KEY.UPDATE, FEATURE_KEY.DELETE], Plugin);
const { superAdmin, isAdmin, isBuilder } = UserAllPermissions;
if (superAdmin || isAdmin || isBuilder) {
// Admin, super admin and Builder can do all operations
can(
[
FEATURE_KEY.INSTALL,
FEATURE_KEY.UPDATE,
FEATURE_KEY.DELETE,
FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS,
FEATURE_KEY.UNINSTALL_PLUGINS,
FEATURE_KEY.DEPENDENT_PLUGINS,
],
Plugin
);
}
// These two operations are available to all
can([FEATURE_KEY.GET_ONE, FEATURE_KEY.RELOAD, FEATURE_KEY.GET], Plugin);

View file

@ -10,5 +10,8 @@ export const FEATURES: FeaturesConfig = {
[FEATURE_KEY.INSTALL]: {},
[FEATURE_KEY.RELOAD]: {},
[FEATURE_KEY.UPDATE]: {},
[FEATURE_KEY.DEPENDENT_PLUGINS]: {},
[FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS]: {},
[FEATURE_KEY.UNINSTALL_PLUGINS]: {},
},
};

View file

@ -5,4 +5,7 @@ export enum FEATURE_KEY {
GET = 'get',
GET_ONE = 'get_one',
RELOAD = 'reload',
DEPENDENT_PLUGINS = 'dependent_plugins',
INSTALL_DEPENDENT_PLUGINS = 'install_dependent_plugins',
UNINSTALL_PLUGINS = 'uninstall_plugins',
}

View file

@ -70,8 +70,24 @@ export class PluginsController implements IPluginsController {
return this.pluginsService.reload(id);
}
@Post('/findDepedentPlugins')
@Post('findDependentPlugins')
@InitFeature(FEATURE_KEY.DEPENDENT_PLUGINS)
async findDependentPluginsToBeInstalledFromDataSources(@Body() dataSources) {
return this.pluginsService.checkIfPluginsToBeInstalled(dataSources);
}
@Post('installDependentPlugins')
@InitFeature(FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS)
async installDependentPlugins(
@Body('dependentPlugins') dependentPlugins,
@Body('shouldAutoImportPlugin') shouldAutoImportPlugin
) {
return this.pluginsService.autoInstallPluginsForTemplates(dependentPlugins, shouldAutoImportPlugin);
}
@Post('uninstallPlugins')
@InitFeature(FEATURE_KEY.UNINSTALL_PLUGINS)
async uninstallPlugins(@Body('pluginsId') pluginsId) {
return this.pluginsService.uninstallPlugins(pluginsId);
}
}

View file

@ -136,7 +136,7 @@ export class PluginsService implements IPluginsService {
return Array.from(marketplacePluginsUsed);
}
private async arePluginsInstalled(pluginsId: Array<string>): Promise<{ pluginsToBeInstalled: Array<string> }> {
private async findPluginsToBeInstalled(pluginsId: Array<string>): Promise<{ pluginsToBeInstalled: Array<string> }> {
const pluginsToBeInstalled = [];
if (!pluginsId.length) return { pluginsToBeInstalled };
@ -154,30 +154,62 @@ export class PluginsService implements IPluginsService {
async checkIfPluginsToBeInstalled(
dataSources
): Promise<{ pluginsToBeInstalled: Array<string>; pluginsListIdToDetailsMap: any }> {
const { pluginsListIdToDetailsMap } = this.listMarketplacePlugins();
const marketplacePluginsUsed = this.filterMarketplacePluginsFromDatasources(dataSources, pluginsListIdToDetailsMap);
const { pluginsToBeInstalled } = await this.arePluginsInstalled(marketplacePluginsUsed);
return { pluginsToBeInstalled, pluginsListIdToDetailsMap };
}
async autoInstallPluginsForTemplates(pluginsToBeInstalled: Array<string>, shouldAutoInstall: boolean) {
const { pluginsListIdToDetailsMap } = this.listMarketplacePlugins();
if (shouldAutoInstall && pluginsToBeInstalled.length) {
const installedPluginsName = [];
for (const pluginId of pluginsToBeInstalled) {
const pluginDetails = pluginsListIdToDetailsMap[pluginId];
const installedPlugin = await this.install(pluginDetails);
installedPluginsName.push(installedPlugin.name);
}
return installedPluginsName;
}
if (!shouldAutoInstall && pluginsToBeInstalled.length) {
throw new NotFoundException(
`Plugins ( ${pluginsToBeInstalled
.map((pluginToBeInstalled) => pluginsListIdToDetailsMap[pluginToBeInstalled].name || pluginToBeInstalled)
.join(', ')} ) is not installed yet!`
try {
const { pluginsListIdToDetailsMap } = this.listMarketplacePlugins();
const marketplacePluginsUsed = this.filterMarketplacePluginsFromDatasources(
dataSources,
pluginsListIdToDetailsMap
);
const { pluginsToBeInstalled } = await this.findPluginsToBeInstalled(marketplacePluginsUsed);
return { pluginsToBeInstalled, pluginsListIdToDetailsMap };
} catch (error) {
throw new InternalServerErrorException(
error,
'An error occurred while checking whether plugins need to be installed.'
);
}
}
async autoInstallPluginsForTemplates(pluginsToBeInstalled: Array<string>, shouldAutoInstall: boolean) {
const installedPluginsList = [];
const installedPluginsInfo = [];
try {
const { pluginsListIdToDetailsMap } = this.listMarketplacePlugins();
if (shouldAutoInstall && pluginsToBeInstalled.length) {
for (const pluginId of pluginsToBeInstalled) {
const pluginDetails = pluginsListIdToDetailsMap[pluginId];
const installedPluginInfo = await this.install(pluginDetails);
installedPluginsList.push(installedPluginInfo.name);
installedPluginsInfo.push(installedPluginInfo);
}
return { installedPluginsList, installedPluginsInfo };
}
if (!shouldAutoInstall && pluginsToBeInstalled.length) {
throw new NotFoundException(
`Plugins ( ${pluginsToBeInstalled
.map((pluginToBeInstalled) => pluginsListIdToDetailsMap[pluginToBeInstalled].name || pluginToBeInstalled)
.join(', ')} ) is not installed yet!`
);
}
} catch (error) {
if (installedPluginsInfo.length) {
const pluginsId = installedPluginsInfo.map((pluginInfo) => pluginInfo.id);
await this.uninstallPlugins(pluginsId);
}
throw new InternalServerErrorException(error, 'Error while installing marketplace plugins');
}
}
async uninstallPlugins(pluginsId: Array<string>) {
try {
if (!pluginsId.length) return;
for (const pluginId of pluginsId) {
await this.remove(pluginId);
}
return;
} catch (error) {
throw new InternalServerErrorException(error, 'Error while uninstalling marketplace plugins');
}
}
}

View file

@ -9,6 +9,9 @@ interface Features {
[FEATURE_KEY.INSTALL]: FeatureConfig;
[FEATURE_KEY.RELOAD]: FeatureConfig;
[FEATURE_KEY.UPDATE]: FeatureConfig;
[FEATURE_KEY.DEPENDENT_PLUGINS]: FeatureConfig;
[FEATURE_KEY.INSTALL_DEPENDENT_PLUGINS]: FeatureConfig;
[FEATURE_KEY.UNINSTALL_PLUGINS]: FeatureConfig;
}
export interface FeaturesConfig {

View file

@ -22,14 +22,14 @@ export class TemplateAppsController {
@User() user,
@Body('identifier') identifier,
@Body('appName') appName,
@Body('dependentPluginsForTemplate') dependentPluginsForTemplate,
@Body('dependentPlugins') dependentPlugins,
@Body('shouldAutoImportPlugin') shouldAutoImportPlugin
) {
const newApp = await this.templatesService.perform(
user,
identifier,
appName,
dependentPluginsForTemplate,
dependentPlugins,
shouldAutoImportPlugin
);

View file

@ -27,12 +27,12 @@ export class TemplatesService {
currentUser: User,
identifier: string,
appName: string,
dependentPluginsForTemplate: Array<string>,
dependentPlugins: Array<string>,
shouldAutoImportPlugin: boolean
) {
const templateDefinition = this.findTemplateDefinition(identifier);
if (dependentPluginsForTemplate.length)
await this.pluginsService.autoInstallPluginsForTemplates(dependentPluginsForTemplate, shouldAutoImportPlugin);
if (dependentPlugins.length)
await this.pluginsService.autoInstallPluginsForTemplates(dependentPlugins, shouldAutoImportPlugin);
return this.importTemplate(currentUser, templateDefinition, appName, identifier);
}

View file

@ -20,6 +20,7 @@ import {
IsIn,
} from 'class-validator';
import { sanitizeInput, formatTimestamp, validateDefaultValue, formatJSONB } from 'src/helpers/utils.helper';
import { TooljetDatabaseDataTypes, TJDB } from '../types';
export function Match(property: string, validationOptions?: ValidationOptions) {
return (object: any, propertyName: string) => {
@ -189,11 +190,11 @@ export class PostgrestTableColumnDto {
@Validate(SQLInjectionValidator, { message: 'Column name does not support special characters' })
column_name: string;
@IsString()
@IsIn(Object.values(TJDB), { message: 'Incorrect datatype.' })
@IsNotEmpty()
@Transform(({ value }) => sanitizeInput(value))
@Validate(SQLInjectionValidator)
data_type: string;
data_type: TooljetDatabaseDataTypes;
@IsOptional()
@Transform(({ value, obj }) => {
@ -290,11 +291,11 @@ export class EditColumnTableDto {
@Validate(SQLInjectionValidator, { message: 'Column name does not support special characters' })
column_name: string;
@IsString()
@IsIn(Object.values(TJDB), { message: 'Incorrect datatype.' })
@IsNotEmpty()
@Transform(({ value }) => sanitizeInput(value))
@Validate(SQLInjectionValidator)
data_type: string;
data_type: TooljetDatabaseDataTypes;
@IsOptional()
@Transform(({ value, obj }) => {

View file

@ -1,6 +1,5 @@
import { QueryFailedError } from 'typeorm';
import { InternalTable } from 'src/entities/internal_table.entity';
import { capitalize } from 'lodash';
export const TJDB = {
character_varying: 'character varying' as const,
@ -150,10 +149,11 @@ export class TooljetDatabaseError extends QueryFailedError {
}
toString(): string {
const capitalizeSentence = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const errorMessage =
errorCodeMapping[this.code]?.[this.context.origin] ||
errorCodeMapping[this.code]?.['default'] ||
capitalize(this.message);
capitalizeSentence(this.message);
return this.replaceErrorPlaceholders(errorMessage);
}