From de9d9b59248bf8ec13d34ebd915af48751e9e2b2 Mon Sep 17 00:00:00 2001 From: Ganesh Kumar <40178541+ganesh8056@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:53:05 +0530 Subject: [PATCH 01/27] graphql plugin url appended with empty urlparams (#12353) --- plugins/packages/common/lib/utils.helper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/packages/common/lib/utils.helper.ts b/plugins/packages/common/lib/utils.helper.ts index aaab6c436e..3d2dc1e047 100644 --- a/plugins/packages/common/lib/utils.helper.ts +++ b/plugins/packages/common/lib/utils.helper.ts @@ -121,8 +121,11 @@ export const sanitizeSearchParams = (sourceOptions: any, queryOptions: any, hasD }); if (!hasDataSource) return _urlParams; + const sanitisedUrlParamsFromSourceOptions = (sourceOptions.url_params || []).filter((o) => { + return o.some((e) => !isEmpty(e)); + }); - const urlParams = _urlParams.concat(sourceOptions.url_params || []); + const urlParams = _urlParams.concat(sanitisedUrlParamsFromSourceOptions || []); return urlParams; }; From 43ac4f251fad74212c66ddd0c2cbdcdefd00e7ca Mon Sep 17 00:00:00 2001 From: Kushagra Srivastava Date: Mon, 28 Apr 2025 10:08:34 +0530 Subject: [PATCH 02/27] add metadata to QueryError and populating metadata in inspector on error (#12501) * add metadata to QueryError Signed-off-by: thesynthax * add TODO, minor changes Signed-off-by: thesynthax * minor changes Signed-off-by: thesynthax * metadata gets populated on query error/failure Signed-off-by: thesynthax * add headers to metadata.response Signed-off-by: thesynthax * redact headers Signed-off-by: thesynthax --------- Signed-off-by: thesynthax --- .../_stores/slices/queryPanelSlice.js | 1 + plugins/packages/common/lib/query.error.ts | 4 ++- plugins/packages/restapi/lib/index.ts | 36 +++++++++++++------ server/src/modules/data-queries/service.ts | 1 + 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js index fd49d2a5e4..0df97ab35e 100644 --- a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js @@ -404,6 +404,7 @@ export const createQueryPanelSlice = (set, get) => ({ isLoading: false, ...(query.kind === 'restapi' ? { + metadata: data.metadata, request: data.data.requestObject, response: data.data.responseObject, responseHeaders: data.data.responseHeaders, diff --git a/plugins/packages/common/lib/query.error.ts b/plugins/packages/common/lib/query.error.ts index 39cd753752..329959e98e 100644 --- a/plugins/packages/common/lib/query.error.ts +++ b/plugins/packages/common/lib/query.error.ts @@ -1,11 +1,13 @@ export class QueryError extends Error { data: Record; description: any; - constructor(message: string | undefined, description: any, data: Record) { + metadata?: unknown; + constructor(message: string | undefined, description: unknown, data: Record, metadata?: unknown) { super(message); this.name = this.constructor.name; this.data = data; this.description = description; + this.metadata = metadata; console.log(this.description); } diff --git a/plugins/packages/restapi/lib/index.ts b/plugins/packages/restapi/lib/index.ts index 529a5f61ff..9d11418fbe 100644 --- a/plugins/packages/restapi/lib/index.ts +++ b/plugins/packages/restapi/lib/index.ts @@ -193,6 +193,7 @@ export default class RestapiQueryService implements QueryService { let result = {}; let requestObject = {}; let responseObject = {}; + let metadata = {}; try { const response = await got(url, requestOptions); @@ -217,24 +218,39 @@ export default class RestapiQueryService implements QueryService { if (error instanceof HTTPError) { const requestUrl = error?.request?.options?.url?.origin + error?.request?.options?.url?.pathname; const requestHeaders = cleanSensitiveData(error?.request?.options?.headers, ['authorization']); - result = { - requestObject: { - requestUrl, - requestHeaders, + + requestObject = { + requestUrl: requestUrl, + requestHeaders: requestHeaders, requestParams: urrl.parse(error.request.requestUrl, true).query, - }, - responseObject: { - statusCode: error.response.statusCode, - responseBody: error.response.body, - }, + }; + + responseObject = { + statusCode: error.response.statusCode, + responseBody: error.response.body, + headers: redactHeaders(error.response.headers), + } + + metadata = { + request: requestObject, + response: responseObject, + }; + + // TODO: Need to remove the request/response related information in result in next MAJOR release. + // This is now shared in `metadata` key. Keeping this here for backward compatibility. + + result = { + requestObject: requestObject, + responseObject: responseObject, responseHeaders: error.response.headers, }; + } if (sourceOptions['auth_type'] === 'oauth2' && error?.response?.statusCode == 401) { throw new OAuthUnauthorizedClientError('Unauthorized status from API server', error.message, result); } - throw new QueryError('Query could not be completed', error.message, result); + throw new QueryError('Query could not be completed', error.message, result, metadata); } return { diff --git a/server/src/modules/data-queries/service.ts b/server/src/modules/data-queries/service.ts index e8852da22d..0a02dea16b 100644 --- a/server/src/modules/data-queries/service.ts +++ b/server/src/modules/data-queries/service.ts @@ -174,6 +174,7 @@ export class DataQueriesService implements IDataQueriesService { message: error.message, description: error.description, data: error.data, + metadata: error.metadata, }; } else { console.log(error); From 8e287dcd23fabeeaa6ea8a01763d647a2c328990 Mon Sep 17 00:00:00 2001 From: Kushagra Srivastava Date: Mon, 28 Apr 2025 10:22:47 +0530 Subject: [PATCH 03/27] empty state copywriting changed (#12588) Signed-off-by: thesynthax --- frontend/src/MarketplacePage/InstalledPlugins.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/MarketplacePage/InstalledPlugins.jsx b/frontend/src/MarketplacePage/InstalledPlugins.jsx index 260b16c189..d0da8512a5 100644 --- a/frontend/src/MarketplacePage/InstalledPlugins.jsx +++ b/frontend/src/MarketplacePage/InstalledPlugins.jsx @@ -68,7 +68,7 @@ export const InstalledPlugins = () => { })} {!fetching && installedPlugins?.length === 0 && (
-

No results found

+

No plugins added. Please add a plugin from the Marketplace.

)} From cb992cd32462778ee66cf14a536bd0aed308107f Mon Sep 17 00:00:00 2001 From: Parth <108089718+parthy007@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:23:25 +0530 Subject: [PATCH 04/27] Enhance: Add validation for encrypted fields with input reference on failure (#12541) * API backend setup for fetching decrypted options object * Frontend setup to use and fetch decrypted options object * Debounce validation and include encrypted fields * Update banners and point back to inputs * Fix toggle and headers to trigger validation on change * Fix disable state of save button --- frontend/src/_components/DynamicFormV2.jsx | 75 +++++++++++++------ .../src/_helpers/dataSourceSchemaManager.js | 22 +++--- frontend/src/_services/datasource.service.js | 11 +++ frontend/src/_ui/HttpHeaders/index.js | 52 ++++++++----- .../DataSourceManager/DataSourceManager.jsx | 16 +++- server/src/modules/data-sources/controller.ts | 7 ++ .../data-sources/interfaces/IController.ts | 2 + server/src/modules/data-sources/service.ts | 10 ++- .../src/modules/data-sources/util.service.ts | 15 ++++ 9 files changed, 157 insertions(+), 53 deletions(-) diff --git a/frontend/src/_components/DynamicFormV2.jsx b/frontend/src/_components/DynamicFormV2.jsx index 1a6269976f..681e14918b 100644 --- a/frontend/src/_components/DynamicFormV2.jsx +++ b/frontend/src/_components/DynamicFormV2.jsx @@ -30,6 +30,8 @@ const DynamicFormV2 = ({ validationMessages, setValidationMessages, clearValidationMessages, + showValidationErrors, + clearValidationErrorBanner, }) => { const uiProperties = schema['tj:ui:properties'] || {}; const dsm = React.useMemo(() => new DataSourceSchemaManager(schema), [schema]); @@ -89,18 +91,48 @@ const DynamicFormV2 = ({ 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); + const timeout = setTimeout(() => { + validateOptions(); + }, 300); + + return () => clearTimeout(timeout); + }, [options, hasUserInteracted, validateOptions]); + + const validateOptions = React.useCallback(async () => { + try { + const { valid, errors } = await dsm.validateData(options); + + if (valid) { + clearValidationMessages(); + clearValidationErrorBanner(); + } else { + setValidationMessages(errors, schema); + const requiredFields = errors + .filter((error) => error.keyword === 'required') + .map((error) => error.params.missingProperty); + setConditionallyRequiredProperties(requiredFields); + } + } catch (error) { + console.error('❌ Validation error:', error); } - }, [options]); + }, [ + dsm, + options, + clearValidationMessages, + clearValidationErrorBanner, + setValidationMessages, + schema, + setConditionallyRequiredProperties, + ]); + + React.useEffect(() => { + if (showValidationErrors) { + setHasUserInteracted(true); + const allFieldKeys = Object.keys(options); + setInteractedFields(new Set(allFieldKeys)); + } + }, [showValidationErrors, options]); React.useEffect(() => { const prevDataSourceId = prevDataSourceIdRef.current; @@ -210,8 +242,10 @@ const DynamicFormV2 = ({ const isRequired = required || conditionallyRequiredProperties.includes(key); const isEncrypted = widget === 'password-v3' || encryptedProperties.includes(key); const currentValue = options?.[key]?.value; + const skipValidation = + (!hasUserInteracted && !showValidationErrors) || (!interactedFields.has(key) && !showValidationErrors); - const handleOptionChange = (key, value, flag) => { + const handleOptionChange = (key, value, flag = true) => { if (!hasUserInteracted) { setHasUserInteracted(true); } @@ -262,14 +296,13 @@ const DynamicFormV2 = ({ 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 + isValidatedMessages: skipValidation + ? { valid: null, message: '' } // skip validation for initial render and untouched elements + : validationMessages[key] + ? { valid: false, message: validationMessages[key] } + : isRequired + ? { valid: true, message: '' } + : { valid: null, message: '' }, // handle optional && encrypted fields isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(), }; } @@ -285,7 +318,7 @@ const DynamicFormV2 = ({ options: isRenderedAsQueryEditor ? options?.[key] ?? schema?.defaults?.[key] : options?.[key]?.value ?? schema?.defaults?.[key]?.value, - optionchanged, + handleOptionChange, isRenderedAsQueryEditor, workspaceConstants: currentOrgEnvironmentConstants, isDisabled: !canUpdateDataSource(selectedDataSource?.id) && !canDeleteDataSource(), @@ -298,7 +331,7 @@ const DynamicFormV2 = ({ return { defaultChecked: currentValue, checked: currentValue, - onChange: (e) => optionchanged(key, e.target.checked), + onChange: (e) => handleOptionChange(key, e.target.checked, true), }; case 'dropdown': case 'dropdown-component-flip': diff --git a/frontend/src/_helpers/dataSourceSchemaManager.js b/frontend/src/_helpers/dataSourceSchemaManager.js index abf2f3c9bb..6ce4f6f9a7 100644 --- a/frontend/src/_helpers/dataSourceSchemaManager.js +++ b/frontend/src/_helpers/dataSourceSchemaManager.js @@ -1,3 +1,4 @@ +import { datasourceService } from '@/_services'; import Ajv2020 from 'ajv'; const ajvOptions = { @@ -18,13 +19,18 @@ export default class DataSourceSchemaManager { this.validate = this.ajv.compile(this.schema); } - validateData(options) { - const data = this._convertDataSourceOptionsToData(options); - const valid = this.validate(data); - if (!valid) { - return { valid: false, errors: this.validate.errors }; + async validateData(options) { + const decryptedOptions = await datasourceService.getDecryptedOptions(options); + const data = this._convertDataSourceOptionsToData(decryptedOptions); + try { + const valid = this.validate(data); + if (!valid) { + return { valid: false, errors: this.validate.errors }; + } + return { valid: true, errors: [] }; + } catch (error) { + console.log('Validtion error: ', error); } - return { valid: true, errors: [] }; } getDefaults(options = {}) { @@ -95,10 +101,6 @@ export default class DataSourceSchemaManager { result[key] = value; } - // Add a dummy value to pass validation for encrypted keys - if (this.getEncryptedProperties().includes(key)) { - result[key] = 'REDACTED'; - } return result; }, {}); } diff --git a/frontend/src/_services/datasource.service.js b/frontend/src/_services/datasource.service.js index ef1a878db7..9ea24e99d0 100644 --- a/frontend/src/_services/datasource.service.js +++ b/frontend/src/_services/datasource.service.js @@ -11,8 +11,19 @@ export const datasourceService = { save, fetchOauth2BaseUrl, testSampleDb, + getDecryptedOptions, }; +function getDecryptedOptions(options) { + const requestOptions = { + method: 'POST', + headers: authHeader(), + credentials: 'include', + body: JSON.stringify(options), + }; + return fetch(`${config.apiUrl}/data-sources/decrypt`, requestOptions).then(handleResponse); +} + function getAll(appVersionId, environment_id, includeStaticSources = false) { const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' }; let searchParams = new URLSearchParams( diff --git a/frontend/src/_ui/HttpHeaders/index.js b/frontend/src/_ui/HttpHeaders/index.js index 04bbd8e3b5..3f254d1497 100644 --- a/frontend/src/_ui/HttpHeaders/index.js +++ b/frontend/src/_ui/HttpHeaders/index.js @@ -1,13 +1,14 @@ -import React from "react"; -import _ from "lodash"; -import QueryEditor from "./QueryEditor"; -import SourceEditor from "./SourceEditor"; -import { deepClone } from "@/_helpers/utilities/utils.helpers"; +import React from 'react'; +import _ from 'lodash'; +import QueryEditor from './QueryEditor'; +import SourceEditor from './SourceEditor'; +import { deepClone } from '@/_helpers/utilities/utils.helpers'; export default ({ getter, - options = [["", ""]], + options = [['', '']], optionchanged, + handleOptionChange, isRenderedAsQueryEditor, workspaceConstants, isDisabled, @@ -16,27 +17,46 @@ export default ({ dataCy, }) => { function addNewKeyValuePair(options) { - const newPairs = [...options, ["", ""]]; - optionchanged(getter, newPairs); + const newPairs = [...options, ['', '']]; + + if (handleOptionChange) { + handleOptionChange(getter, newPairs, true); + } else { + optionchanged(getter, newPairs); + } } function removeKeyValuePair(index) { const newOptions = [...options]; newOptions.splice(index, 1); - optionchanged(getter, newOptions); + if (handleOptionChange) { + handleOptionChange(getter, newOptions, true); + } else { + optionchanged(getter, newOptions); + } } function keyValuePairValueChanged(value, keyIndex, index) { if (!isRenderedAsQueryEditor) { const newOptions = deepClone(options); newOptions[index][keyIndex] = value; - options.length - 1 === index - ? addNewKeyValuePair(newOptions) - : optionchanged(getter, newOptions); + if (options.length - 1 === index) { + addNewKeyValuePair(newOptions); + } else { + if (handleOptionChange) { + handleOptionChange(getter, newOptions, true); + } else { + optionchanged(getter, newOptions); + } + } } else { let newOptions = deepClone(options); newOptions[index][keyIndex] = value; - optionchanged(getter, newOptions); + if (handleOptionChange) { + handleOptionChange(getter, newOptions, true); + } else { + optionchanged(getter, newOptions); + } } } @@ -53,10 +73,6 @@ export default ({ return isRenderedAsQueryEditor ? ( ) : ( - + ); }; diff --git a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx index dd19801dfd..a90c394ce5 100644 --- a/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx +++ b/frontend/src/modules/dataSources/components/DataSourceManager/DataSourceManager.jsx @@ -84,6 +84,7 @@ class DataSourceManagerComponent extends React.Component { creatingApp: false, validationError: [], validationMessages: {}, + showValidationErrors: false, }; } @@ -219,11 +220,13 @@ class DataSourceManagerComponent extends React.Component { dataSourceMeta, dataSourceSchema, validationMessages, + validationError, } = this.state; if (!isEmpty(validationMessages)) { const validationMessageArray = Object.values(validationMessages); - this.setState({ validationError: validationMessageArray }); + this.setState({ validationError: validationMessageArray, showValidationErrors: true }); + toast.error( this.props.t( 'editor.queryManager.dataSourceManager.toast.error.validationFailed', @@ -379,10 +382,12 @@ class DataSourceManagerComponent extends React.Component { return acc; }, {}); this.setState({ validationMessages: errorMap }); + const validationMessageArray = Object.values(this.state.validationMessages); + this.setState({ validationError: validationMessageArray }); }; renderSourceComponent = (kind, isPlugin = false) => { - const { options, isSaving } = this.state; + const { options, isSaving, showValidationErrors } = this.state; const sourceComponentName = kind?.charAt(0).toUpperCase() + kind?.slice(1); const ComponentToRender = isPlugin ? SourceComponent : SourceComponents[sourceComponentName] || SourceComponent; @@ -402,6 +407,8 @@ class DataSourceManagerComponent extends React.Component { setValidationMessages={this.setValidationMessages} clearValidationMessages={() => this.setState({ validationMessages: {} })} setDefaultOptions={this.setDefaultOptions} + showValidationErrors={showValidationErrors} + clearValidationErrorBanner={() => this.setState({ validationError: [] })} /> ); }; @@ -901,6 +908,7 @@ class DataSourceManagerComponent extends React.Component { addingDataSource, datasourceName, validationError, + validationMessages, } = this.state; const isPlugin = dataSourceSchema ? true : false; const createSelectedDataSource = (dataSource) => { @@ -910,7 +918,9 @@ class DataSourceManagerComponent extends React.Component { const sampleDBmodalBodyStyle = isSampleDb ? { paddingBottom: '0px', borderBottom: '1px solid #E6E8EB' } : {}; const sampleDBmodalFooterStyle = isSampleDb ? { paddingTop: '8px' } : {}; const isSaveDisabled = selectedDataSource - ? deepEqual(options, selectedDataSource?.options, ['encrypted']) && selectedDataSource?.name === datasourceName + ? (deepEqual(options, selectedDataSource?.options, ['encrypted']) && + selectedDataSource?.name === datasourceName) || + !isEmpty(validationMessages) : true; this.props.setGlobalDataSourceStatus({ isEditing: !isSaveDisabled }); const docLink = isSampleDb diff --git a/server/src/modules/data-sources/controller.ts b/server/src/modules/data-sources/controller.ts index a7f5188029..787c8ffdd8 100644 --- a/server/src/modules/data-sources/controller.ts +++ b/server/src/modules/data-sources/controller.ts @@ -128,4 +128,11 @@ export class DataSourcesController implements IDataSourcesController { await this.dataSourcesService.authorizeOauth2(id, environmentId, authorizeDataSourceOauthDto, user); return; } + + @InitFeature(FEATURE_KEY.AUTHORIZE) + @UseGuards(FeatureAbilityGuard) + @Post('decrypt') + async decryptOptions(@Body() options: Record) { + return await this.dataSourcesService.decryptOptions(options); + } } diff --git a/server/src/modules/data-sources/interfaces/IController.ts b/server/src/modules/data-sources/interfaces/IController.ts index d332dd9e7b..0b849aebfe 100644 --- a/server/src/modules/data-sources/interfaces/IController.ts +++ b/server/src/modules/data-sources/interfaces/IController.ts @@ -44,4 +44,6 @@ export interface IDataSourcesController { environmentId: string, authorizeDataSourceOauthDto: AuthorizeDataSourceOauthDto ): Promise; + + decryptOptions(options: Record): Promise; } diff --git a/server/src/modules/data-sources/service.ts b/server/src/modules/data-sources/service.ts index 51fdbcbf1e..c591300899 100644 --- a/server/src/modules/data-sources/service.ts +++ b/server/src/modules/data-sources/service.ts @@ -161,6 +161,10 @@ export class DataSourcesService implements IDataSourcesService { return; } + async decryptOptions(options: Record) { + return await this.dataSourcesUtilService.decrypt(options); + } + async delete(dataSourceId: string, user: User) { const dataSource = await this.dataSourcesRepository.findById(dataSourceId); if (!dataSource) { @@ -206,7 +210,11 @@ export class DataSourcesService implements IDataSourcesService { async testSampleDBConnection(testDataSourceDto: TestSampleDataSourceDto, user: User) { const { environment_id, dataSourceId } = testDataSourceDto; - const dataSource = await this.dataSourcesUtilService.findOneByEnvironment(dataSourceId,user.defaultOrganizationId, environment_id); + const dataSource = await this.dataSourcesUtilService.findOneByEnvironment( + dataSourceId, + user.defaultOrganizationId, + environment_id + ); testDataSourceDto.options = dataSource.options; return await this.dataSourcesUtilService.testConnection(testDataSourceDto, user.organizationId); } diff --git a/server/src/modules/data-sources/util.service.ts b/server/src/modules/data-sources/util.service.ts index b4329dd4c5..e01f20aa0e 100644 --- a/server/src/modules/data-sources/util.service.ts +++ b/server/src/modules/data-sources/util.service.ts @@ -214,6 +214,21 @@ export class DataSourcesUtilService implements IDataSourcesUtilService { }); } + async decrypt(options: Record) { + const decryptedOptions = { ...options }; + + for (const [key, value] of Object.entries(options)) { + if (value?.credential_id) { + decryptedOptions[key] = { + ...value, + value: await this.credentialService.getValue(value.credential_id), + }; + } + } + + return decryptedOptions; + } + async parseOptionsForUpdate(dataSource: DataSource, options: Array, manager: EntityManager) { if (!options) return {}; From 219440a9bc2c1be7f1ab9334da18972e128385f0 Mon Sep 17 00:00:00 2001 From: Parth <108089718+parthy007@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:24:56 +0530 Subject: [PATCH 05/27] Add copy-writing and icon fixes (#12664) --- .../plugins/azurerepos/lib/manifest.json | 2 +- marketplace/plugins/pinecone/lib/icon.svg | 78 ++----------------- plugins/packages/nocodb/lib/manifest.json | 9 +-- server/src/assets/marketplace/plugins.json | 60 +++++++++----- 4 files changed, 53 insertions(+), 96 deletions(-) diff --git a/marketplace/plugins/azurerepos/lib/manifest.json b/marketplace/plugins/azurerepos/lib/manifest.json index fd15e29b9f..f14ec918df 100644 --- a/marketplace/plugins/azurerepos/lib/manifest.json +++ b/marketplace/plugins/azurerepos/lib/manifest.json @@ -4,7 +4,7 @@ "description": "A schema defining Azurerepos datasource", "type": "api", "source": { - "name": "Azurerepos", + "name": "Azure Repos", "kind": "azurerepos", "exposedVariables": { "isLoading": false, diff --git a/marketplace/plugins/pinecone/lib/icon.svg b/marketplace/plugins/pinecone/lib/icon.svg index 2bddce89fd..8681b2a90c 100644 --- a/marketplace/plugins/pinecone/lib/icon.svg +++ b/marketplace/plugins/pinecone/lib/icon.svg @@ -1,72 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - diff --git a/plugins/packages/nocodb/lib/manifest.json b/plugins/packages/nocodb/lib/manifest.json index 1c8283574a..80a0ff6a2c 100644 --- a/plugins/packages/nocodb/lib/manifest.json +++ b/plugins/packages/nocodb/lib/manifest.json @@ -1,11 +1,10 @@ - { "$schema": "https://raw.githubusercontent.com/ToolJet/ToolJet/develop/plugins/schemas/manifest.schema.json", "title": "Nocodb datasource", "description": "A schema defining Nocodb datasource", "type": "api", "source": { - "name": "Nocodb", + "name": "NocoDB", "kind": "nocodb", "exposedVariables": { "isLoading": false, @@ -15,14 +14,14 @@ "options": { "api_token": { "encrypted": true - } + } }, "customTesting": true }, "defaults": { "api_token": { "value": "" - } + } }, "properties": { "host": { @@ -61,4 +60,4 @@ "required": [ "api_token" ] -} +} \ No newline at end of file diff --git a/server/src/assets/marketplace/plugins.json b/server/src/assets/marketplace/plugins.json index 7024fad778..fe24e4a6a7 100644 --- a/server/src/assets/marketplace/plugins.json +++ b/server/src/assets/marketplace/plugins.json @@ -1,6 +1,6 @@ [ { - "name": "plivo", + "name": "Plivo", "description": "Plugin for Plivo APIs", "version": "1.0.0", "id": "plivo", @@ -22,7 +22,9 @@ "id": "openai", "author": "Tooljet", "timestamp": "Mon, 10 Apr 2023 06:33:21 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "AWS Textract", @@ -35,7 +37,7 @@ { "name": "HarperDB", "repo": "", - "description": "Plugin for HarperDB data source", + "description": "Plugin to store and query data from HarperDB", "version": "1.0.0", "id": "harperdb", "author": "Tooljet", @@ -82,7 +84,7 @@ "timestamp": "Thu, 29 Feb 2024 09:46:21 GMT" }, { - "name": "salesforce", + "name": "Salesforce", "description": "API plugin from salesforce", "version": "1.0.0", "id": "salesforce", @@ -91,7 +93,7 @@ }, { "name": "Presto", - "description": "Plugin for PrestoDB data source", + "description": "Plugin for open source SQL query engine", "version": "1.0.0", "id": "presto", "author": "Tooljet", @@ -107,7 +109,7 @@ }, { "name": "Jira", - "description": "Plugin for Jira data source", + "description": "Integrate with Jira for projects management", "version": "1.0.0", "id": "jira", "author": "Tooljet", @@ -120,16 +122,20 @@ "id": "portkey", "author": "Portkey", "timestamp": "Sat, 29 Jun 2024 09:40:13 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Pinecone", - "description": "Plugin for Pinecone Vector DB", + "description": "Plugin to store and manage data in Vector DB", "version": "1.0.0", "id": "pinecone", "author": "Tooljet", "timestamp": "Mon, 28 Oct 2024 08:08:28 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Cohere", @@ -138,7 +144,9 @@ "id": "cohere", "author": "Tooljet", "timestamp": "Tue, 21 Jan 2025 05:09:30 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Mistral", @@ -147,7 +155,9 @@ "id": "mistral_ai", "author": "Tooljet", "timestamp": "Tue, 21 Jan 2025 06:35:01 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Hugging Face", @@ -156,7 +166,9 @@ "id": "hugging_face", "author": "Tooljet", "timestamp": "Thu, 23 Jan 2025 06:44:25 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Gemini", @@ -165,7 +177,9 @@ "id": "gemini", "author": "Tooljet", "timestamp": "Fri, 17 Jan 2025 18:04:48 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Anthropic", @@ -174,16 +188,20 @@ "id": "anthropic", "author": "Tooljet", "timestamp": "Mon, 20 Jan 2025 08:04:46 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Qdrant", - "description": "Plugin for Qdrant APIs", + "description": "Plugin to store and manage data in Vector DB", "version": "1.0.0", "id": "qdrant", "author": "Tooljet", "timestamp": "Tue, 10 Dec 2024 02:11:32 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { "name": "Weaviate DB", @@ -192,14 +210,16 @@ "id": "weaviate", "author": "Tooljet", "timestamp": "Tue, 21 Jan 2025 16:55:28 GMT", - "tags": ["AI"] + "tags": [ + "AI" + ] }, { - "name": "azurerepos", - "description": "api plugin from azurerepos", + "name": "Azure Repos", + "description": "Plugin for managing Azure repositories", "version": "1.0.0", "id": "azurerepos", "author": "Tooljet", "timestamp": "Mon, 23 Dec 2024 11:57:30 GMT" } -] +] \ No newline at end of file From f82bfddb8c026d08687cb5ebc652cd92a9a4ac08 Mon Sep 17 00:00:00 2001 From: Parth <108089718+parthy007@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:01:41 +0530 Subject: [PATCH 06/27] Enhance: Plugin schema for validation and design component (#12655) * API backend setup for fetching decrypted options object * Frontend setup to use and fetch decrypted options object * Debounce validation and include encrypted fields * Update banners and point back to inputs * Remove ssl config from connection string in postgresql * Add support for textarea design component * Improve conditional requirement logic * Fix validation banner bugs * Change schema for airtable * Change schema for bigquery * Change schema for mongodb --- frontend/src/_components/DynamicFormV2.jsx | 70 +++- frontend/src/_styles/theme.scss | 15 + frontend/src/_ui/Input-V3/index.js | 3 +- .../components/ui/Input/CommonInput/Index.jsx | 17 +- .../ui/Input/CommonInput/TextInput.jsx | 5 +- frontend/src/components/ui/Input/Input.jsx | 43 ++- .../DataSourceManager/DataSourceManager.jsx | 13 +- plugins/packages/airtable/lib/manifest.json | 46 ++- plugins/packages/bigquery/lib/manifest.json | 42 ++- plugins/packages/mongodb/lib/manifest.json | 323 ++++++++++++------ plugins/packages/postgresql/lib/index.ts | 1 - plugins/packages/postgresql/lib/manifest.json | 9 +- 12 files changed, 389 insertions(+), 198 deletions(-) diff --git a/frontend/src/_components/DynamicFormV2.jsx b/frontend/src/_components/DynamicFormV2.jsx index 681e14918b..bada082e16 100644 --- a/frontend/src/_components/DynamicFormV2.jsx +++ b/frontend/src/_components/DynamicFormV2.jsx @@ -103,29 +103,78 @@ const DynamicFormV2 = ({ try { const { valid, errors } = await dsm.validateData(options); + const conditionallyRequiredFields = processAllOfConditions(schema, options); + setConditionallyRequiredProperties(conditionallyRequiredFields); + if (valid) { clearValidationMessages(); clearValidationErrorBanner(); } else { - setValidationMessages(errors, schema); - const requiredFields = errors - .filter((error) => error.keyword === 'required') - .map((error) => error.params.missingProperty); - setConditionallyRequiredProperties(requiredFields); + setValidationMessages(errors, schema, interactedFields); } } catch (error) { - console.error('❌ Validation error:', error); + console.error('Validation error:', error); } }, [ dsm, options, + processAllOfConditions, + schema, clearValidationMessages, clearValidationErrorBanner, setValidationMessages, - schema, - setConditionallyRequiredProperties, + interactedFields, ]); + const processAllOfConditions = React.useCallback((schema, options, path = []) => { + let requiredFields = []; + + if (schema.allOf) { + schema.allOf.forEach((condition) => { + if (condition.if && condition.then) { + const conditionMatches = Object.entries(condition.if.properties || {}).every(([propName, propCondition]) => { + const propertyPath = [...path, propName]; + + let currentValue = options; + for (const segment of propertyPath) { + if (!currentValue || typeof currentValue !== 'object') { + return false; + } + currentValue = currentValue[segment]?.value; + } + + return propCondition.const === currentValue; + }); + + if (conditionMatches) { + if (condition.then.required) { + requiredFields = [...requiredFields, ...condition.then.required]; + } + + if (condition.then.allOf) { + const nestedRequired = processAllOfConditions({ allOf: condition.then.allOf }, options, path); + requiredFields = [...requiredFields, ...nestedRequired]; + } + + if (condition.then.properties) { + Object.entries(condition.then.properties).forEach(([propName, propSchema]) => { + if (propSchema.allOf) { + const nestedRequired = processAllOfConditions({ allOf: propSchema.allOf }, options, [ + ...path, + propName, + ]); + requiredFields = [...requiredFields, ...nestedRequired]; + } + }); + } + } + } + }); + } + + return requiredFields; + }, []); + React.useEffect(() => { if (showValidationErrors) { setHasUserInteracted(true); @@ -221,6 +270,7 @@ const DynamicFormV2 = ({ return Input; case 'password-v3': case 'text-v3': + case 'password-v3-textarea': return InputV3; case 'textarea': return Textarea; @@ -277,6 +327,7 @@ const DynamicFormV2 = ({ }; } case 'password-v3': + case 'password-v3-textarea': case 'text-v3': { return { key, @@ -338,7 +389,7 @@ const DynamicFormV2 = ({ return { options: list, value: options?.[key]?.value || options?.[key], - onChange: (value) => optionchanged(key, value), + onChange: (value) => handleOptionChange(key, value, true), width: width || '100%', encrypted: options?.[key]?.encrypted, }; @@ -437,6 +488,7 @@ const DynamicFormV2 = ({ {label && widget !== 'text-v3' && widget !== 'password-v3' && + widget !== 'password-v3-textarea' && renderLabel(label, uiProperties[key].tooltip)} )} diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index c423bf5a85..189cd045db 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -12336,6 +12336,17 @@ tbody { } } + .design-component-inputs textarea { + + &.valid-textarea { + border: 1.5px solid #519b62 !important; + } + + &.invalid-textarea { + border: 1.5px solid #e26367 !important; + } + } + } .tj-app-input-wrapper { @@ -13132,6 +13143,10 @@ tbody { background-color: var(--slate3) !important; } + textarea:disabled { + background-color: var(--slate3) !important; + } + .react-select__control--is-disabled { background-color: var(--slate3) !important; } diff --git a/frontend/src/_ui/Input-V3/index.js b/frontend/src/_ui/Input-V3/index.js index abc819c80a..71b2b3cf2a 100644 --- a/frontend/src/_ui/Input-V3/index.js +++ b/frontend/src/_ui/Input-V3/index.js @@ -43,7 +43,7 @@ const InputV3 = ({ helpText, ...props }) => { required={props.isRequired} /> )} - {(widget === 'password-v3' || encrypted) && ( + {(widget === 'password-v3' || widget === 'password-v3-textarea' || encrypted) && (
{ label={props.label} placeholder={props.placeholder} required={props.isRequired} + multiline={widget === 'password-v3-textarea'} />
)} diff --git a/frontend/src/components/ui/Input/CommonInput/Index.jsx b/frontend/src/components/ui/Input/CommonInput/Index.jsx index 0710876ac3..baee3ad5d8 100644 --- a/frontend/src/components/ui/Input/CommonInput/Index.jsx +++ b/frontend/src/components/ui/Input/CommonInput/Index.jsx @@ -32,21 +32,28 @@ const CommonInput = ({ label, helperText, disabled, required, onChange: change, } }, [isValidatedMessages]); + useEffect(() => { + if (isValid === true && (!isValidatedMessages || isValidatedMessages.valid === null)) { + setIsValid(true); + } + }, [isValid, isValidatedMessages]); + const toggleEditing = () => { if (isDisabled) return; const willBeInEditMode = !isEditing; setIsEditing(willBeInEditMode); - - if (willBeInEditMode) { - change({ target: { value: '' } }); - } + change({ target: { value: '' } }); }; return (
- {label && } + {label && ( +
+ +
+ )} {type === 'password' && (
diff --git a/frontend/src/components/ui/Input/CommonInput/TextInput.jsx b/frontend/src/components/ui/Input/CommonInput/TextInput.jsx index 1834d4799d..978e69798f 100644 --- a/frontend/src/components/ui/Input/CommonInput/TextInput.jsx +++ b/frontend/src/components/ui/Input/CommonInput/TextInput.jsx @@ -14,14 +14,14 @@ const TextInput = ({ readOnly, ...restProps }) => { - const inputStyle = `tw-border-border-default placeholder:tw-text-text-placeholder tw-font-normal disabled:tw-bg-[#CCD1D5]/30 ${ + const inputStyle = `placeholder:tw-text-text-placeholder tw-font-normal disabled:tw-bg-[#CCD1D5]/30 ${ leadingIcon ? (size === 'small' ? 'tw-pl-[32px]' : 'tw-pl-[34px]') : 'tw-pl-[12px]' } ${trailingAction ? (size === 'small' ? 'tw-pr-[40px]' : 'tw-pr-[44px]') : 'tw-pr-[12px]'} ${ response === true ? '!tw-border-border-success-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-success-strong' : response === false ? '!tw-border-border-danger-strong focus-visible:!tw-ring-0 focus-visible:!tw-ring-offset-0 focus-visible:!tw-border-border-danger-strong' - : '' + : 'tw-border-border-default' }`; return ( @@ -39,6 +39,7 @@ const TextInput = ({ size={size} placeholder={disabled && readOnly ? readOnly : placeholder} disabled={disabled} + response={response} {...restProps} className={inputStyle} /> diff --git a/frontend/src/components/ui/Input/Input.jsx b/frontend/src/components/ui/Input/Input.jsx index 33c3194f0b..9d09b97009 100644 --- a/frontend/src/components/ui/Input/Input.jsx +++ b/frontend/src/components/ui/Input/Input.jsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'; import { inputVariants } from './InputUtils/Variants'; import SolidIcon from '../../../_ui/Icon/SolidIcons'; -const Input = React.forwardRef(({ className, size, type, ...props }, ref) => { +const Input = React.forwardRef(({ className, size, type, multiline, response, rows = 3, ...props }, ref) => { const [isPasswordVisible, setIsPasswordVisible] = React.useState(false); const isPasswordField = type === 'password'; @@ -13,19 +13,34 @@ const Input = React.forwardRef(({ className, size, type, ...props }, ref) => { } }; + const validationClass = response === true ? 'valid-textarea' : response === false ? 'invalid-textarea' : ''; + return ( - <> - - {isPasswordField && ( +
+ {multiline ? ( +