From 7b844f51512e47b856095ca265a40543027259b2 Mon Sep 17 00:00:00 2001 From: Adish M <44204658+adishM98@users.noreply.github.com> Date: Fri, 28 Mar 2025 15:33:13 +0530 Subject: [PATCH 01/12] Fixed forked branch checkout issue in Render deployment (#12407) Issue: - Forked branch was not being pulled by Render create service. Fix: - Added fix in preview code to correctly fetch and checkout to forked branch. --- .github/workflows/render-preview-deploy.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/render-preview-deploy.yml b/.github/workflows/render-preview-deploy.yml index ead9ba50bf..d4700d06e7 100644 --- a/.github/workflows/render-preview-deploy.yml +++ b/.github/workflows/render-preview-deploy.yml @@ -13,12 +13,31 @@ 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 PR is from the same repo + id: check_repo + run: echo "::set-output name=is_fork::$(if [[ '${{ github.event.pull_request.head.repo.full_name }}' != '${{ github.event.pull_request.base.repo.full_name }}' ]]; then echo true; else echo false; fi)" + + - name: Fetch the remote branch if it's a forked PR + if: steps.check_repo.outputs.is_fork == 'true' + run: | + git fetch origin pull/${{ github.event.number }}/head:${{ env.BRANCH_NAME }} + git checkout ${{ env.BRANCH_NAME }} + + - name: Checkout + if: steps.check_repo.outputs.is_fork == 'false' + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + + - name: Creating deployment for CE id: create-ce-deployment run: | From 40ec66344194da1fbdd25d4975d99b7adfbc2992 Mon Sep 17 00:00:00 2001 From: Adish M Date: Fri, 28 Mar 2025 15:52:18 +0530 Subject: [PATCH 02/12] =?UTF-8?q?Fix=20forked=20branch=20handling=20by=20d?= =?UTF-8?q?ynamically=20setting=20the=20repo=20URL.=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect if the branch is from a fork - Dynamically set the repo URL to use the fork's owner - Ensure correct repository reference during workflow execution --- .github/workflows/render-preview-deploy.yml | 31 ++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/render-preview-deploy.yml b/.github/workflows/render-preview-deploy.yml index d4700d06e7..203ee88150 100644 --- a/.github/workflows/render-preview-deploy.yml +++ b/.github/workflows/render-preview-deploy.yml @@ -21,22 +21,33 @@ jobs: - name: Sync repo uses: actions/checkout@v3 - - name: Check if PR is from the same repo + - name: Check if Forked Repository id: check_repo - run: echo "::set-output name=is_fork::$(if [[ '${{ github.event.pull_request.head.repo.full_name }}' != '${{ github.event.pull_request.base.repo.full_name }}' ]]; then echo true; else echo false; fi)" + 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: Fetch the remote branch if it's a forked PR - if: steps.check_repo.outputs.is_fork == 'true' + - 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 - if: steps.check_repo.outputs.is_fork == 'false' + - name: Checkout Default Branch + if: env.is_fork == 'false' uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.ref }} - - name: Creating deployment for CE id: create-ce-deployment @@ -53,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": [], From 573537159e3ea24371b3571c0a853ed53e6fe98a Mon Sep 17 00:00:00 2001 From: Akshay Sasidharan Date: Fri, 28 Mar 2025 18:46:34 +0530 Subject: [PATCH 03/12] add azurerepos in plugins assets --- server/src/assets/marketplace/plugins.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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" } ] From b6b89c0b901f75126da1ffc607272370f16d3db8 Mon Sep 17 00:00:00 2001 From: Parth <108089718+parthy007@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:53:40 +0530 Subject: [PATCH 04/12] Change azurerepo icon (#12444) --- marketplace/plugins/azurerepos/lib/icon.svg | 83 ++++----------------- 1 file changed, 13 insertions(+), 70 deletions(-) diff --git a/marketplace/plugins/azurerepos/lib/icon.svg b/marketplace/plugins/azurerepos/lib/icon.svg index 2bddce89fd..44aa77adc7 100644 --- a/marketplace/plugins/azurerepos/lib/icon.svg +++ b/marketplace/plugins/azurerepos/lib/icon.svg @@ -1,72 +1,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - From 246dafb64dad0624beb47e171777352e6454953b Mon Sep 17 00:00:00 2001 From: Manish Kushare <37823141+manishkushare@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:59:50 +0530 Subject: [PATCH 05/12] Fix: In TJDB the error message text not having proper column name while uploading bulk data (#12346) * Fix: In TJDB the error message text not having proper column name while uploading bulk data * Change the variable name and removed capitalize loadsh method --- server/src/modules/tooljet-db/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); } From 11471421748d1b45599f8d399a15dbdaa5b1675d Mon Sep 17 00:00:00 2001 From: Kushagra Srivastava Date: Thu, 3 Apr 2025 12:00:16 +0530 Subject: [PATCH 06/12] Added detailed error descriptions in BigQuery Plugin (#12384) * Added detailed error descriptions in BigQuery Plugin Signed-off-by: thesynthax * Update index.ts --------- Signed-off-by: thesynthax --- plugins/packages/bigquery/lib/index.ts | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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 { From 04bc740d954217c884eb7bf1f73b7eb3e8411843 Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Thu, 3 Apr 2025 12:02:39 +0530 Subject: [PATCH 07/12] Feat: Auto install plugin based on queries on app import (#12350) * dependent plugins will be auto imported on App import * added error handling * added error handling for edge cases * API permissions are updated --- frontend/src/HomePage/HomePage.jsx | 77 ++++++++++++++---- frontend/src/_components/AppModal.jsx | 6 +- .../_components/PluginsListForAppModal.jsx | 6 +- frontend/src/_services/library-app.service.js | 4 +- frontend/src/_services/plugins.service.js | 39 +++++++++ server/src/modules/plugins/ability/index.ts | 18 ++++- .../src/modules/plugins/constants/features.ts | 3 + server/src/modules/plugins/constants/index.ts | 3 + server/src/modules/plugins/controller.ts | 18 ++++- server/src/modules/plugins/service.ts | 80 +++++++++++++------ server/src/modules/plugins/types/index.ts | 3 + server/src/modules/templates/controller.ts | 4 +- server/src/modules/templates/service.ts | 6 +- 13 files changed, 208 insertions(+), 59 deletions(-) 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/PluginsListForAppModal.jsx b/frontend/src/_components/PluginsListForAppModal.jsx index 56d7e19504..074e3b9025 100644 --- a/frontend/src/_components/PluginsListForAppModal.jsx +++ b/frontend/src/_components/PluginsListForAppModal.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import SolidIcon from '@/_ui/Icon/SolidIcons'; import config from 'config'; -export const PluginsListForAppModal = ({ dependentPluginsForTemplate, dependentPluginsDetail }) => { +export const PluginsListForAppModal = ({ dependentPlugins, dependentPluginsDetail }) => { const [isExpanded, setIsExpanded] = useState(false); const toggleExpanded = () => { @@ -29,7 +29,7 @@ export const PluginsListForAppModal = ({ dependentPluginsForTemplate, dependentP )} - {isExpanded && dependentPluginsForTemplate && dependentPluginsForTemplate.length > 0 && ( + {isExpanded && dependentPlugins && dependentPlugins.length > 0 && (
- {dependentPluginsForTemplate.map((plugin, index) => { + {dependentPlugins.map((plugin, index) => { const pluginsName = dependentPluginsDetail[plugin].name || plugin; const iconSrc = `${config.TOOLJET_MARKETPLACE_URL}/marketplace-assets/${plugin}/lib/icon.svg`; return ( diff --git a/frontend/src/_services/library-app.service.js b/frontend/src/_services/library-app.service.js index ed8b464c71..815fa560c0 100644 --- a/frontend/src/_services/library-app.service.js +++ b/frontend/src/_services/library-app.service.js @@ -8,11 +8,11 @@ export const libraryAppService = { findDependentPluginsInTemplate, }; -function deploy(identifier, appName, dependentPluginsForTemplate = [], shouldAutoImportPlugin = false) { +function deploy(identifier, appName, dependentPlugins = [], shouldAutoImportPlugin = false) { const body = { identifier, appName, - dependentPluginsForTemplate, + dependentPlugins, shouldAutoImportPlugin, }; diff --git a/frontend/src/_services/plugins.service.js b/frontend/src/_services/plugins.service.js index 14437b1c97..e66cdd759c 100644 --- a/frontend/src/_services/plugins.service.js +++ b/frontend/src/_services/plugins.service.js @@ -1,4 +1,6 @@ import HttpClient from '@/_helpers/http-client'; +import config from 'config'; +import { authHeader, handleResponse } from '@/_helpers'; const adapter = new HttpClient(); @@ -22,10 +24,47 @@ function reloadPlugin(id) { return adapter.post(`/plugins/${id}/reload`); } +function findDependentPlugins(dataSources) { + return adapter.post(`/plugins/findDependentPlugins`, dataSources); +} + +function installDependentPlugins(dependentPlugins, shouldAutoImportPlugin) { + const body = { + dependentPlugins, + shouldAutoImportPlugin, + }; + + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/plugins/installDependentPlugins`, requestOptions).then(handleResponse); +} + +function uninstallPlugins(pluginsId) { + const body = { + pluginsId: pluginsId, + }; + + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(body), + }; + + return fetch(`${config.apiUrl}/plugins/uninstallPlugins`, requestOptions).then(handleResponse); +} + export const pluginsService = { findAll, installPlugin, updatePlugin, deletePlugin, reloadPlugin, + findDependentPlugins, + installDependentPlugins, + uninstallPlugins, }; diff --git a/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); } From 739f8a2eb30bf5afe48361ca28775493acc11547 Mon Sep 17 00:00:00 2001 From: Manish Kushare <37823141+manishkushare@users.noreply.github.com> Date: Thu, 3 Apr 2025 12:03:41 +0530 Subject: [PATCH 08/12] [Fix] : The data in the time field of the calendar is not visible and when scrolling vertically, blank space appears (#12352) * Bug fixed * Bug fixed , line height issue in column form for table edit and create table * Popover position issue fixed * Enhance DateTimePicker popper styling and adjust class usage for better positioning --- .../TooljetDatabase/DateTimePicker/styles.scss | 7 +++++++ .../DateTimePicker/DateTimePicker.jsx | 4 ++-- .../TooljetDatabase/DateTimePicker/styles.scss | 12 +++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) 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; + } + } +} From 86590f58c40d4377345544ba3137f5b870699432 Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Thu, 3 Apr 2025 12:04:08 +0530 Subject: [PATCH 09/12] Fix: DTO validation for data types in ToolJet database (#12368) * datasource configuration page mounted when no particular datasource is selected * dto validation for TJDB table datatype and app export by specific versioin exports tjdb tables --- frontend/src/HomePage/ExportAppModal.jsx | 2 +- .../dataSources/components/GlobalDataSources/index.jsx | 2 +- server/src/modules/tooljet-db/dto/index.ts | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) 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/modules/dataSources/components/GlobalDataSources/index.jsx b/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx index 1ab03f6e72..9975bf42d4 100644 --- a/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx +++ b/frontend/src/modules/dataSources/components/GlobalDataSources/index.jsx @@ -474,7 +474,7 @@ export const GlobalDataSources = ({ darkMode = false, updateSelectedDatasource }
- {containerRef && containerRef?.current && ( + {containerRef && containerRef?.current && selectedDataSource && ( { @@ -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 }) => { From cb5a03ac692219e9f6d275b33972c1521efa72e3 Mon Sep 17 00:00:00 2001 From: Akshay Sasidharan Date: Thu, 3 Apr 2025 13:44:41 +0530 Subject: [PATCH 10/12] update git submodules --- frontend/ee | 2 +- server/ee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/server/ee b/server/ee index 0eefbb71a1..683647f83d 160000 --- a/server/ee +++ b/server/ee @@ -1 +1 @@ -Subproject commit 0eefbb71a1d5288f49641af5efaaab25970f27d1 +Subproject commit 683647f83d3efeeadbe69c40b8e8dd5ba4e8ea06 From 4b6e6ee5cdefc1ee65297b7c8ce653563ca2794b Mon Sep 17 00:00:00 2001 From: Akshay Date: Thu, 3 Apr 2025 13:47:49 +0530 Subject: [PATCH 11/12] Feature: Dynamic form validations (#12292) * fixed datasource page crash as function definition was referenced wrongly (#11562) * Add new dynamicform * Refactor postgres manifest file * Add new input-v3 component * Conditionally render DynamicformV2 * Make change to design system component * Remove key-value label over header input and increase width * Add validation function for individual inputs * Add validations on datasource creation * Update custom input wrapper * Update manifest file * Add validation setup for dynamic form with JSON schema * Fix input labels * Add more validation checks * Update manifest * Remove console logs * Add props for header component * Skip validation for encrypted fields * Add validations while saving datasource * Remove validations for connection-options * Add fetch manifest function * Centralise validation errors * Add property name in datapath * Initialize and map validation errors to property * Reuse validationErrors while saving datasource * Bypass design system validation by implementing custom validation prop * Skip initial render validation Skip validation message for unchanged elements * Remove fetchManifest * Add text input for connection string * Add workflow schema * Fix double border on error or success * Remove redundant default populating logic * Fix the error helper text color to red * Validate all fields post initial render * Show label name in helper-text for failed validation * Correctly switch between the password eye svg * Incorporate edit button on encrypted inputs * Resolve lint issue --------- Co-authored-by: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Co-authored-by: Parth Adhikari Co-authored-by: Parth Adhikari Co-authored-by: Parth Adhikari Co-authored-by: parthy007 --- frontend/src/_components/DynamicFormV2.jsx | 510 ++++++++++++++++++ .../src/_helpers/dataSourceSchemaManager.js | 119 ++++ frontend/src/_ui/HttpHeaders/SourceEditor.jsx | 2 +- frontend/src/_ui/Input-V3/index.js | 85 +++ .../components/ui/Input/CommonInput/Index.jsx | 69 ++- .../ui/Input/CommonInput/NumberInput.jsx | 6 +- .../ui/Input/CommonInput/TextInput.jsx | 6 +- frontend/src/components/ui/Input/Index.jsx | 2 +- frontend/src/components/ui/Input/Input.jsx | 40 +- .../ui/Input/InputUtils/InputUtils.jsx | 2 +- .../components/DataSourceComponents/index.js | 76 ++- .../DataSourceManager/DataSourceManager.jsx | 66 ++- plugins/packages/postgresql/lib/manifest.json | 351 +++++++----- 13 files changed, 1162 insertions(+), 172 deletions(-) create mode 100644 frontend/src/_components/DynamicFormV2.jsx create mode 100644 frontend/src/_helpers/dataSourceSchemaManager.js create mode 100644 frontend/src/_ui/Input-V3/index.js 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} +
+ ))} +
+
+ )} +