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 && (
e.stopPropagation()}>
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) && ( + + )} + +
+ + + {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 && ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - diff --git a/plugins/packages/bigquery/lib/index.ts b/plugins/packages/bigquery/lib/index.ts index b154736ed6..37f09db6c3 100644 --- a/plugins/packages/bigquery/lib/index.ts +++ b/plugins/packages/bigquery/lib/index.ts @@ -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 { diff --git a/plugins/packages/postgresql/lib/manifest.json b/plugins/packages/postgresql/lib/manifest.json index 75b18f67f6..36b57efc24 100644 --- a/plugins/packages/postgresql/lib/manifest.json +++ b/plugins/packages/postgresql/lib/manifest.json @@ -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" - ] + } } \ No newline at end of file diff --git a/server/.version b/server/.version index 7c69a55dbb..19811903a7 100644 --- a/server/.version +++ b/server/.version @@ -1 +1 @@ -3.7.0 +3.8.0 diff --git a/server/ee b/server/ee index 0eefbb71a1..683647f83d 160000 --- a/server/ee +++ b/server/ee @@ -1 +1 @@ -Subproject commit 0eefbb71a1d5288f49641af5efaaab25970f27d1 +Subproject commit 683647f83d3efeeadbe69c40b8e8dd5ba4e8ea06 diff --git a/server/src/assets/marketplace/plugins.json b/server/src/assets/marketplace/plugins.json index a312406724..7024fad778 100644 --- a/server/src/assets/marketplace/plugins.json +++ b/server/src/assets/marketplace/plugins.json @@ -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" } ] diff --git a/server/src/modules/plugins/ability/index.ts b/server/src/modules/plugins/ability/index.ts index ee0d6dcaf9..1bdc0c1a96 100644 --- a/server/src/modules/plugins/ability/index.ts +++ b/server/src/modules/plugins/ability/index.ts @@ -15,10 +15,20 @@ export class FeatureAbilityFactory extends AbilityFactory } protected defineAbilityFor(can: AbilityBuilder['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); diff --git a/server/src/modules/plugins/constants/features.ts b/server/src/modules/plugins/constants/features.ts index 230ffc7f43..43e1412455 100644 --- a/server/src/modules/plugins/constants/features.ts +++ b/server/src/modules/plugins/constants/features.ts @@ -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]: {}, }, }; diff --git a/server/src/modules/plugins/constants/index.ts b/server/src/modules/plugins/constants/index.ts index dcf733519e..2fb9051cb0 100644 --- a/server/src/modules/plugins/constants/index.ts +++ b/server/src/modules/plugins/constants/index.ts @@ -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', } diff --git a/server/src/modules/plugins/controller.ts b/server/src/modules/plugins/controller.ts index 7a80771bee..cb3b4816e7 100644 --- a/server/src/modules/plugins/controller.ts +++ b/server/src/modules/plugins/controller.ts @@ -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); + } } diff --git a/server/src/modules/plugins/service.ts b/server/src/modules/plugins/service.ts index 7b6fd0782b..48398d5ebe 100644 --- a/server/src/modules/plugins/service.ts +++ b/server/src/modules/plugins/service.ts @@ -136,7 +136,7 @@ export class PluginsService implements IPluginsService { return Array.from(marketplacePluginsUsed); } - private async arePluginsInstalled(pluginsId: Array): Promise<{ pluginsToBeInstalled: Array }> { + private async findPluginsToBeInstalled(pluginsId: Array): Promise<{ pluginsToBeInstalled: Array }> { const pluginsToBeInstalled = []; if (!pluginsId.length) return { pluginsToBeInstalled }; @@ -154,30 +154,62 @@ export class PluginsService implements IPluginsService { async checkIfPluginsToBeInstalled( dataSources ): Promise<{ pluginsToBeInstalled: Array; 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, 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, 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) { + 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'); + } + } } diff --git a/server/src/modules/plugins/types/index.ts b/server/src/modules/plugins/types/index.ts index 0abca9f13a..e073b0f1df 100644 --- a/server/src/modules/plugins/types/index.ts +++ b/server/src/modules/plugins/types/index.ts @@ -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 { diff --git a/server/src/modules/templates/controller.ts b/server/src/modules/templates/controller.ts index 70479a63d7..98a85c71ce 100644 --- a/server/src/modules/templates/controller.ts +++ b/server/src/modules/templates/controller.ts @@ -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 ); diff --git a/server/src/modules/templates/service.ts b/server/src/modules/templates/service.ts index 42abdbf251..81483e1514 100644 --- a/server/src/modules/templates/service.ts +++ b/server/src/modules/templates/service.ts @@ -27,12 +27,12 @@ export class TemplatesService { currentUser: User, identifier: string, appName: string, - dependentPluginsForTemplate: Array, + dependentPlugins: Array, 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); } diff --git a/server/src/modules/tooljet-db/dto/index.ts b/server/src/modules/tooljet-db/dto/index.ts index 09dca956b2..a6bc6f6823 100644 --- a/server/src/modules/tooljet-db/dto/index.ts +++ b/server/src/modules/tooljet-db/dto/index.ts @@ -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 }) => { diff --git a/server/src/modules/tooljet-db/types.ts b/server/src/modules/tooljet-db/types.ts index 73b72013aa..86f0f4ec8c 100644 --- a/server/src/modules/tooljet-db/types.ts +++ b/server/src/modules/tooljet-db/types.ts @@ -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); }