diff --git a/.version b/.version index afad818663..92536a9e48 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -3.11.0 +3.12.0 diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.skip.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.js rename to cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/airTableHappyPath.cy.skip.js diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/bigqueryHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/bigqueryHappyPath.cy.skip.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/bigqueryHappyPath.cy.js rename to cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/bigqueryHappyPath.cy.skip.js diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mongoDbHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mongoDbHappyPath.cy.skip.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mongoDbHappyPath.cy.js rename to cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mongoDbHappyPath.cy.skip.js diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mysqlHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mysqlHappyPath.cy.skip.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mysqlHappyPath.cy.js rename to cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/mysqlHappyPath.cy.skip.js diff --git a/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/sqlServerHappyPath.cy.js b/cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/sqlServerHappyPath.cy.skip.js similarity index 100% rename from cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/sqlServerHappyPath.cy.js rename to cypress-tests/cypress/e2e/happyPath/marketplace/commonTestcases/data-source/sqlServerHappyPath.cy.skip.js diff --git a/frontend/.version b/frontend/.version index afad818663..92536a9e48 100644 --- a/frontend/.version +++ b/frontend/.version @@ -1 +1 @@ -3.11.0 +3.12.0 diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUploadPrimaryKey.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUploadPrimaryKey.jsx index d1a9dfa3aa..3a74038367 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUploadPrimaryKey.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUploadPrimaryKey.jsx @@ -53,7 +53,11 @@ export const BulkUploadPrimaryKey = () => {
{ diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUpsertPrimaryKey.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUpsertPrimaryKey.jsx new file mode 100644 index 0000000000..07ddc48742 --- /dev/null +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/BulkUpsertPrimaryKey.jsx @@ -0,0 +1,78 @@ +import React, { useContext, useEffect } from 'react'; +import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; +import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; +import CodeHinter from '@/AppBuilder/CodeEditor'; + +export const BulkUpsertPrimaryKey = () => { + const { + columns, + bulkUpsertPrimaryKey, + handleBulkUpsertRowsOptionChanged, + handlePrimaryKeyOptionChangedForBulkUpsert, + } = useContext(TooljetDatabaseContext); + + useEffect(() => { + const primaryKeys = columns.reduce((acc, column) => { + if (column?.keytype === 'PRIMARY KEY' || column?.isPrimaryKey) { + acc.push(column?.accessor); + } + return acc; + }, []); + + if (primaryKeys.length > 0) { + handlePrimaryKeyOptionChangedForBulkUpsert(primaryKeys); + } + }, [columns]); + + const handleRowsChange = (value) => { + handleBulkUpsertRowsOptionChanged(value); + }; + + return ( +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ ); +}; + +export default BulkUpsertPrimaryKey; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx index 89323c1c3c..e273e2ace8 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx @@ -1,5 +1,4 @@ import CodeHinter from '@/AppBuilder/CodeEditor'; -import { resolveReferences } from '@/Editor/CodeEditor/utils'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import Trash from '@/_ui/Icon/solidIcons/Trash'; import React from 'react'; @@ -43,8 +42,7 @@ const RenderColumnUI = ({ placeholder="key" onChange={(newValue) => { if (isJSonTypeColumn) { - const [_, __, resolvedValue] = resolveReferences(`{{${newValue}}}`); - handleValueChange(resolvedValue); + handleValueChange(newValue); } else { handleValueChange(newValue); } diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx index 208f4df816..0ea9538017 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx @@ -220,7 +220,9 @@ function DataSourceSelect({ if (isFirstPageLoaded && offset >= totalRecords) return; if (foreignKeys.length < 1) return; setIsLoadingFKDetails(true); - const referencedColumns = foreignKeys.find((item) => item.column_names[0] === cellColumnName); + const referencedColumns = Array.isArray(foreignKeys) + ? foreignKeys.find((item) => item.column_names[0] === cellColumnName) + : undefined; if (!referencedColumns?.referenced_column_names?.length) return; const selectQuery = new PostgrestQueryBuilder(); @@ -709,7 +711,8 @@ const MenuList = ({ ...props }) => { const menuListStyles = getStyles('menuList', props); - const referencedColumnDetails = foreignKeys?.find((item) => item.column_names[0] === cellColumnName); + const referencedColumnDetails = + Array.isArray(foreignKeys) && foreignKeys?.find((item) => item?.column_names[0] === cellColumnName); const handleNavigateToReferencedTable = () => { const data = { diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx index e873228888..f25e72cb59 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations.jsx @@ -16,6 +16,7 @@ import { getPrivateRoute } from '@/_helpers/routes'; import { useNavigate } from 'react-router-dom'; import { deepClone } from '@/_helpers/utilities/utils.helpers'; import { BulkUploadPrimaryKey } from './BulkUploadPrimaryKey'; +import BulkUpsertPrimaryKey from './BulkUpsertPrimaryKey'; import './styles.scss'; import CodeHinter from '@/AppBuilder/CodeEditor'; @@ -46,6 +47,7 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay const [tableForeignKeyInfo, setTableForeignKeyInfo] = useState({}); const [bulkUpdatePrimaryKey, setBulkUpdatePrimaryKey] = useState(() => options['bulk_update_with_primary_key'] || {}); + const [bulkUpsertPrimaryKey, setBulkUpsertPrimaryKey] = useState(() => options['bulk_upsert_with_primary_key'] || {}); const joinOptions = options['join_table']?.['joins'] || [ { conditions: { conditionsList: [{ leftField: { table: selectedTableId } }] } }, @@ -196,6 +198,11 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay // eslint-disable-next-line react-hooks/exhaustive-deps }, [bulkUpdatePrimaryKey]); + useEffect(() => { + mounted && optionchanged('bulk_upsert_with_primary_key', bulkUpsertPrimaryKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bulkUpsertPrimaryKey]); + useEffect(() => { mounted && optionchanged('update_rows', updateRowsOptions); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -234,10 +241,18 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay setBulkUpdatePrimaryKey((prev) => ({ ...prev, rows_update: value })); }; + const handleBulkUpsertRowsOptionChanged = (value) => { + setBulkUpsertPrimaryKey((prev) => ({ ...prev, rows: value })); + }; + const handlePrimaryKeyOptionChangedForBulkUpdate = (value) => { setBulkUpdatePrimaryKey((prev) => ({ ...prev, primary_key: value })); }; + const handlePrimaryKeyOptionChangedForBulkUpsert = (value) => { + setBulkUpsertPrimaryKey((prev) => ({ ...prev, primary_key: value })); + }; + const loadTableInformation = async (tableId, isNewTableAdded) => { const tableDetails = findTableDetails(tableId); if (tableDetails?.table_name && !tableInfo[tableDetails?.table_name]) { @@ -340,8 +355,11 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay tableForeignKeyInfo, setTableForeignKeyInfo, bulkUpdatePrimaryKey, + bulkUpsertPrimaryKey, handleBulkUpdateWithPrimaryKeysRowsUpdateOptionChanged, + handleBulkUpsertRowsOptionChanged, handlePrimaryKeyOptionChangedForBulkUpdate, + handlePrimaryKeyOptionChangedForBulkUpsert, }), [ organizationId, @@ -357,6 +375,7 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay joinOrderByOptions, selectedTableId, bulkUpdatePrimaryKey, + bulkUpsertPrimaryKey, ] ); @@ -517,6 +536,8 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay return JoinTable; case 'bulk_update_with_primary_key': return BulkUploadPrimaryKey; + case 'bulk_upsert_with_primary_key': + return BulkUpsertPrimaryKey; } }; @@ -527,6 +548,7 @@ const ToolJetDbOperations = ({ optionchanged, options, darkMode, isHorizontalLay { label: 'Delete rows', value: 'delete_rows' }, { label: 'Join tables', value: 'join_tables' }, { label: 'Bulk update with primary key', value: 'bulk_update_with_primary_key' }, + { label: 'Bulk upsert with primary key', value: 'bulk_upsert_with_primary_key' }, ]; const ComponentToRender = getComponent(operation); diff --git a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js index 5ca9429693..ddb67a5445 100644 --- a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js @@ -425,6 +425,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/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx index 92772ffea1..d480f2c698 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx @@ -220,7 +220,9 @@ function DataSourceSelect({ if (isFirstPageLoaded && offset >= totalRecords) return; if (foreignKeys.length < 1) return; setIsLoadingFKDetails(true); - const referencedColumns = foreignKeys.find((item) => item.column_names[0] === cellColumnName); + const referencedColumns = Array.isArray(foreignKeys) + ? foreignKeys.find((item) => item.column_names[0] === cellColumnName) + : undefined; if (!referencedColumns?.referenced_column_names?.length) return; const selectQuery = new PostgrestQueryBuilder(); @@ -715,7 +717,8 @@ const MenuList = ({ ...props }) => { const menuListStyles = getStyles('menuList', props); - const referencedColumnDetails = foreignKeys?.find((item) => item.column_names[0] === cellColumnName); + const referencedColumnDetails = + Array.isArray(foreignKeys) && foreignKeys.find((item) => item?.column_names[0] === cellColumnName); const handleNavigateToReferencedTable = () => { const data = { 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.

)}
diff --git a/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx b/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx index 41cedf6bc4..d847f4238c 100644 --- a/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/ColumnForm.jsx @@ -342,8 +342,39 @@ const ColumnForm = ({ )}
-
- Default value +
+
+ Default value +
+ {foreignKeyDetails?.length > 0 && isForeignKey && ( + 0 && isForeignKey} + > +
+ Set default value to Null + +
+
+ )}
-
+
{isTimestamp ? ( ) : ( - - - No data found -
- } - loader={ - <> - - - - - } - isLoading={true} - value={foreignKeyDefaultValue} - foreignKeyAccessInRowForm={true} - disabled={dataType === 'serial'} - topPlaceHolder={dataType === 'serial' ? 'Auto-generated' : 'Enter a value'} - onChange={(value) => { - setForeignKeyDefaultValue(value); - setDefaultValue(value?.value); - }} - onAdd={true} - addBtnLabel={'Open referenced table'} - foreignKeys={foreignKeyDetails} - setReferencedColumnDetails={setReferencedColumnDetails} - scrollEventForColumnValues={true} - cellColumnName={columnName} - columnDataType={dataType?.value} - isCreateColumn={true} - /> + <> + + + No data found +
+ } + loader={ + <> + + + + + } + isLoading={true} + value={foreignKeyDefaultValue} + foreignKeyAccessInRowForm={true} + disabled={dataType === 'serial'} + topPlaceHolder={ + dataType === 'serial' + ? 'Auto-generated' + : foreignKeyDefaultValue?.value === null + ? 'Null' + : 'Enter a value' + } + onChange={(value) => { + setForeignKeyDefaultValue(value); + setDefaultValue(value?.value); + }} + onAdd={true} + addBtnLabel={'Open referenced table'} + foreignKeys={foreignKeyDetails} + setReferencedColumnDetails={setReferencedColumnDetails} + scrollEventForColumnValues={true} + cellColumnName={columnName} + columnDataType={dataType?.value} + isCreateColumn={true} + /> + {defaultValue === null &&

Null

} + )}
- {isNotNull === true && dataType?.value !== 'serial' && rows.length > 0 && defaultValue.length <= 0 ? ( + {isNotNull === true && dataType?.value !== 'serial' && defaultValue?.length <= 0 ? ( Default value is required to populate this field in existing rows as NOT NULL constraint is added @@ -546,6 +596,10 @@ const ColumnForm = ({ checked={isNotNull} onChange={(e) => { setIsNotNull(e.target.checked); + if (e.target.checked && defaultValue === null) { + setForeignKeyDefaultValue({ label: '', value: '' }); + setDefaultValue(''); + } }} disabled={dataType?.value === 'serial'} /> @@ -602,7 +656,7 @@ const ColumnForm = ({ shouldDisableCreateBtn={ isEmpty(columnName) || isEmpty(dataType) || - (isNotNull === true && rows.length > 0 && isEmpty(defaultValue) && dataType?.value !== 'serial') || + (dataType?.value !== 'serial' && isNotNull === true && isEmpty(defaultValue)) || disabledSaveButton } showToolTipForFkOnReadDocsSection={true} diff --git a/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx b/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx index 3943964a16..071faf5e3e 100644 --- a/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx @@ -31,6 +31,8 @@ import DateTimePicker from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/D import { getLocalTimeZone, timeZonesWithOffsets } from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/util'; import CodeHinter from '@/AppBuilder/CodeEditor'; import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; +import Switch from '@/AppBuilder/CodeBuilder/Elements/Switch'; +import PostgrestQueryBuilder from '@/_helpers/postgrestQueryBuilder'; const ColumnForm = ({ onClose, @@ -102,6 +104,68 @@ const ColumnForm = ({ const [foreignKeyDetails, setForeignKeyDetails] = useState([]); + // Add function to validate default value + const validateDefaultValue = async () => { + if (!isMatchingForeignKeyColumn(selectedColumn?.Header)) return; + + try { + const referencedColumns = foreignKeys.find((item) => item.column_names[0] === selectedColumn?.Header); + + if (!referencedColumns?.referenced_column_names?.length) { + setForeignKeyDefaultValue({ + value: '', + label: '', + }); + setDefaultValue(''); + return; + } + + const selectQuery = new PostgrestQueryBuilder(); + selectQuery.select(referencedColumns.referenced_column_names[0]); + selectQuery.eq(referencedColumns.referenced_column_names[0], defaultValue); + + const query = selectQuery.url.toString(); + + const { data = [], error } = await tooljetDatabaseService.findOne( + organizationId, + referencedColumns.referenced_table_id, + query + ); + + if (error) { + toast.error(error?.message ?? `Failed to validate default value`); + setForeignKeyDefaultValue({ + value: '', + label: '', + }); + setDefaultValue(''); + return; + } + + if (data.length === 0) { + setForeignKeyDefaultValue({ + value: '', + label: '', + }); + setDefaultValue(''); + } + } catch (error) { + console.error('Error validating default value:', error); + setForeignKeyDefaultValue({ + value: '', + label: '', + }); + setDefaultValue(''); + } + }; + + // Add useEffect to validate on mount + useEffect(() => { + if (isMatchingForeignKeyColumn(selectedColumn?.Header) && defaultValue) { + validateDefaultValue(); + } + }, []); + useEffect(() => { toast.dismiss(); setForeignKeyDetails( @@ -471,6 +535,11 @@ const ColumnForm = ({ setDisabledSaveButton(columnName === ''); }, [columnName]); + useEffect(() => { + const shouldDisableForNullValue = dataType?.value !== 'serial' && isNotNull === true && isEmpty(defaultValue); + setDisabledSaveButton(shouldDisableForNullValue); + }, [isNotNull, defaultValue, dataType]); + const handleInputError = (bool = false) => { setDisabledSaveButton(bool); }; @@ -621,16 +690,52 @@ const ColumnForm = ({
)}
-
- Default value +
+
+ Default value +
+ {isMatchingForeignKeyColumn(selectedColumn?.Header) && ( + +
+ Set default value to Null + +
+
+ )}
+ -
+
{isTimestamp ? ( ) : ( - - - No data found -
- } - loader={ - <> - - - - - } - isLoading={true} - value={foreignKeyDefaultValue} - foreignKeyAccessInRowForm={true} - disabled={ - selectedColumn?.dataType === 'serial' || selectedColumn.constraints_type.is_primary_key === true - } - topPlaceHolder={selectedColumn?.dataType === 'serial' ? 'Auto-generated' : 'Enter a value'} - onChange={(value) => { - setForeignKeyDefaultValue(value); - setDefaultValue(value?.value); - }} - onAdd={true} - addBtnLabel={'Open referenced table'} - foreignKeys={foreignKeys} - setReferencedColumnDetails={setReferencedColumnDetails} - scrollEventForColumnValues={true} - cellColumnName={selectedColumn?.Header} - columnDataType={dataType} - isEditColumn={true} - /> + <> + + + No data found +
+ } + loader={ + <> + + + + + } + isLoading={true} + value={foreignKeyDefaultValue} + foreignKeyAccessInRowForm={true} + disabled={ + selectedColumn?.dataType === 'serial' || selectedColumn.constraints_type.is_primary_key === true + } + topPlaceHolder={ + selectedColumn?.dataType === 'serial' + ? 'Auto-generated' + : foreignKeyDefaultValue?.value === null || defaultValue === null + ? 'Null' + : 'Enter a value' + } + onChange={(value) => { + setForeignKeyDefaultValue(value); + setDefaultValue(value?.value); + }} + onAdd={true} + addBtnLabel={'Open referenced table'} + foreignKeys={foreignKeys} + setReferencedColumnDetails={setReferencedColumnDetails} + scrollEventForColumnValues={true} + cellColumnName={selectedColumn?.Header} + columnDataType={dataType} + isEditColumn={true} + /> + {defaultValue === null &&

Null

} + )}
+ {isNotNull === true && dataType?.value !== 'serial' && defaultValue?.length <= 0 ? ( + + Default value is required to populate this field in existing rows as NOT NULL constraint is added + + ) : null} {isNotNull === true && selectedColumn?.dataType !== 'serial' && rows.length > 0 && @@ -866,6 +990,10 @@ const ColumnForm = ({ checked={isNotNull} onChange={(e) => { setIsNotNull(e.target.checked); + if (e.target.checked && defaultValue === null) { + setForeignKeyDefaultValue({ label: '', value: '' }); + setDefaultValue(''); + } }} disabled={selectedColumn?.dataType === 'serial' || selectedColumn?.constraints_type?.is_primary_key} /> diff --git a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx index 5451cc353f..98ff603242 100644 --- a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx @@ -177,7 +177,9 @@ const EditRowForm = ({ } function isMatchingForeignKeyColumnDetails(columnHeader) { - const matchingColumn = foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader); + const matchingColumn = Array.isArray(foreignKeys) + ? foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader) + : undefined; return matchingColumn; } diff --git a/frontend/src/TooljetDatabase/Forms/RowForm.jsx b/frontend/src/TooljetDatabase/Forms/RowForm.jsx index 8fcc9ef152..58dd3d1000 100644 --- a/frontend/src/TooljetDatabase/Forms/RowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/RowForm.jsx @@ -151,7 +151,9 @@ const RowForm = ({ } function isMatchingForeignKeyColumnDetails(columnHeader) { - const matchingColumn = foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader); + const matchingColumn = Array.isArray(foreignKeys) + ? foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader) + : undefined; return matchingColumn; } diff --git a/frontend/src/TooljetDatabase/Table/index.jsx b/frontend/src/TooljetDatabase/Table/index.jsx index 5d98ce70fe..3a1cafa460 100644 --- a/frontend/src/TooljetDatabase/Table/index.jsx +++ b/frontend/src/TooljetDatabase/Table/index.jsx @@ -958,7 +958,9 @@ const Table = ({ collapseSidebar }) => { } function isMatchingForeignKeyColumnDetails(columnHeader) { - const matchingColumn = foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader); + const matchingColumn = Array.isArray(foreignKeys) + ? foreignKeys.find((foreignKey) => foreignKey.column_names[0] === columnHeader) + : undefined; return matchingColumn; } diff --git a/frontend/src/_components/DynamicFormV2.jsx b/frontend/src/_components/DynamicFormV2.jsx index 1a6269976f..bada082e16 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,97 @@ 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); + + const conditionallyRequiredFields = processAllOfConditions(schema, options); + setConditionallyRequiredProperties(conditionallyRequiredFields); + + if (valid) { + clearValidationMessages(); + clearValidationErrorBanner(); + } else { + setValidationMessages(errors, schema, interactedFields); + } + } catch (error) { + console.error('Validation error:', error); } - }, [options]); + }, [ + dsm, + options, + processAllOfConditions, + schema, + clearValidationMessages, + clearValidationErrorBanner, + setValidationMessages, + 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); + const allFieldKeys = Object.keys(options); + setInteractedFields(new Set(allFieldKeys)); + } + }, [showValidationErrors, options]); React.useEffect(() => { const prevDataSourceId = prevDataSourceIdRef.current; @@ -189,6 +270,7 @@ const DynamicFormV2 = ({ return Input; case 'password-v3': case 'text-v3': + case 'password-v3-textarea': return InputV3; case 'textarea': return Textarea; @@ -210,8 +292,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); } @@ -243,6 +327,7 @@ const DynamicFormV2 = ({ }; } case 'password-v3': + case 'password-v3-textarea': case 'text-v3': { return { key, @@ -262,14 +347,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 +369,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,14 +382,14 @@ 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': 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, }; @@ -404,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/_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/_styles/theme.scss b/frontend/src/_styles/theme.scss index e464a7b86f..1162f50687 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -12345,6 +12345,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 { @@ -13141,6 +13152,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/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/_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 ? ( +