diff --git a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx index 9f677dd4f3..937e39de05 100644 --- a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx +++ b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useResolveStore } from '@/_stores/resolverStore'; -import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; import './styles.scss'; import SingleLineCodeEditor from './SingleLineCodeEditor'; @@ -11,12 +10,14 @@ import Tooltip from 'react-bootstrap/Tooltip'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; import { isNumber } from 'lodash'; import { Alert } from '@/_ui/Alert/Alert'; +import TJDBCodeEditor from './TJDBHinter'; const CODE_EDITOR_TYPE = { fxEditor: SingleLineCodeEditor.EditorBridge, basic: SingleLineCodeEditor, multiline: MultiLineCodeEditor, extendedSingleLine: SingleLineCodeEditor, + tjdbHinter: TJDBCodeEditor, }; const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, ...restProps }) => { @@ -93,7 +94,7 @@ const PopupIcon = ({ callback, icon, tip, position, isMultiEditor = false }) => overlay={{tip}} > { + const { + darkMode, + initialValue, + lang, + className, + onChange, + componentName, + placeholder, + + portalProps, + paramLabel = '', + readOnly = false, + editable = true, + footerComponent = () => noop, + errorCallback = () => noop, + showErrorMessage = false, + reset = false, + defaultValue = null, + shouldUpdateToNullVal = false, + columnName = '', + } = props; + + const mounted = useMounted(); + const [currentValue, setCurrentValue] = React.useState(() => initialValue); + + const [errorState, setErrorState] = React.useState(false); + const [error, setError] = React.useState(null); + + const theme = darkMode ? okaidia : githubLight; + const langExtention = langSupport[lang] ?? null; + + // eslint-disable-next-line react-hooks/exhaustive-deps + + const { handleTogglePopupExapand, isOpen, setIsOpen, forceUpdate } = portalProps; + let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; + + const handleOnChange = (value) => { + if (value === '') { + setErrorState(false); + setError(null); + setCurrentValue(value); + return; + } + + try { + // Try to parse the value as JSON + const parsedValue = JSON.parse(value); + + if (!_.isObject(parsedValue)) { + setErrorState(true); + setError('Expected a JSON object'); + throw new Error(''); + } else { + setErrorState(false); + setError(null); + } + } catch (err) { + // If JSON parsing fails, it's not valid JSON + setErrorState(true); + setError('Invalid JSON'); + } + + setCurrentValue(value); + }; + + useEffect(() => { + //hack : this use effect is for edit row drawer, on initial render, custom value was always "" + if (!currentValue) setCurrentValue(initialValue); + }, [initialValue]); + + useLayoutEffect(() => { + if (mounted && reset && defaultValue) { + handleLowPriorityWork(() => setCurrentValue(JSON.stringify(defaultValue))); + } + }, [reset, defaultValue]); + + useLayoutEffect(() => { + if (!mounted) return; + if (shouldUpdateToNullVal) { + handleLowPriorityWork(() => setCurrentValue('null')); + } else { + !reset && handleLowPriorityWork(() => setCurrentValue(initialValue)); + } + }, [shouldUpdateToNullVal]); + + useEffect(() => { + if (reset || shouldUpdateToNullVal) return; + onChange(currentValue); + }, [currentValue]); + + useEffect(() => { + errorCallback(errorState); + }, [errorState]); + + const setupConfig = { + lineNumbers: false, + syntaxHighlighting: true, + bracketMatching: true, + foldGutter: false, + highlightActiveLine: false, + autocompletion: false, + highlightActiveLineGutter: false, + completionKeymap: false, + searchKeymap: false, + }; + + return ( +
+
+ + + +
+ +
+
+ + {' '} + + + Invalid JSON syntax +
+
{footerComponent()}
+
+
+
+
+ ); +}; + +export default TJDBCodeEditor; diff --git a/frontend/src/AppBuilder/CodeEditor/styles.scss b/frontend/src/AppBuilder/CodeEditor/styles.scss index a3bf82a97e..a94404c434 100644 --- a/frontend/src/AppBuilder/CodeEditor/styles.scss +++ b/frontend/src/AppBuilder/CodeEditor/styles.scss @@ -583,4 +583,21 @@ .disabled-pointerevents { pointer-events: none; +} + +.portal-container:has(.tjdb-portal-codehinter){ + z-index: 100000 !important; +} + +.tjdb-codehinter-wrapper-drawer{ + .codehinter-error-container{ + background: transparent; + font-size: 12px; + color: var(--tomato9); + } +} +.portal-container:has(.tjdb-hinter-error){ + .codehinter-error-container{ + display: none !important; + } } \ No newline at end of file diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/CreateRow.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/CreateRow.jsx index 221b46241b..2175751614 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/CreateRow.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/CreateRow.jsx @@ -1,11 +1,12 @@ -import React, { useState, useEffect, useContext } from 'react'; +import React, { useState, useEffect, useContext, useRef } from 'react'; import { TooljetDatabaseContext } from '@/TooljetDatabase/index'; import { v4 as uuidv4 } from 'uuid'; -import { isEmpty } from 'lodash'; +import _, { isEmpty } from 'lodash'; import { useMounted } from '@/_hooks/use-mount'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import RenderColumnUI from './RenderColumnUI'; import { NoCondition } from './NoConditionUI'; +import cx from 'classnames'; export const CreateRow = React.memo(({ optionchanged, options, darkMode }) => { const mounted = useMounted(); @@ -50,7 +51,11 @@ export const CreateRow = React.memo(({ optionchanged, options, darkMode }) => { Columns -
+
{isEmpty(columnOptions) && } {!isEmpty(columnOptions) && Object.entries(columnOptions).map(([key, value]) => ( @@ -72,7 +77,7 @@ export const CreateRow = React.memo(({ optionchanged, options, darkMode }) => { variant="ghostBlue" size="sm" onClick={addNewColumnOptionsPair} - className={isEmpty(columnOptions) ? '' : 'mt-2'} + className="d-flex justify-content-start width-fit-content" > { - const filteredColumns = columns.filter(({ column_default }) => !column_default?.startsWith('nextval(')); + const filteredColumns = columns.filter(({ column_default }) => + _.isObject(column_default) ? true : !column_default?.startsWith('nextval(') + ); const existingColumnOption = Object.values ? Object.values(columnOptions) : []; - let displayColumns = filteredColumns.map(({ accessor }) => ({ + let displayColumns = filteredColumns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); if (existingColumnOption.length > 0) { @@ -119,7 +127,6 @@ const RenderColumnOptions = ({ }; const newColumnOptions = { ...columnOptions, [id]: updatedOption }; - handleColumnOptionChange(newColumnOptions); }; @@ -133,6 +140,7 @@ const RenderColumnOptions = ({ handleColumnOptionChange(newColumnOptions); }; + const currentColumnType = columns?.find((columnDetails) => columnDetails.accessor === column)?.dataType; return ( ); }; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DeleteRows.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DeleteRows.jsx index f21c10a094..fca7b1c83d 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DeleteRows.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/DeleteRows.jsx @@ -115,16 +115,21 @@ const RenderFilterFields = ({ updateFilterOptionsChanged, deleteRowsOptions, darkMode, + jsonpath = '', }) => { - let displayColumns = columns.map(({ accessor }) => ({ + let displayColumns = columns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); operator = operators.find((val) => val.value === operator); const handleColumnChange = (selectedOption) => { - updateFilterOptionsChanged({ ...deleteRowsOptions?.where_filters[id], ...{ column: selectedOption.value } }); + updateFilterOptionsChanged({ + ...deleteRowsOptions?.where_filters[id], + ...{ column: selectedOption.value, columnDataType: selectedOption?.dataType || '' }, + }); }; const handleOperatorChange = (selectedOption) => { @@ -135,6 +140,15 @@ const RenderFilterFields = ({ updateFilterOptionsChanged({ ...deleteRowsOptions?.where_filters[id], ...{ value: newValue } }); }; + const handleJsonPathChange = (value) => { + updateFilterOptionsChanged({ + ...deleteRowsOptions?.where_filters[id], + jsonpath: value, + }); + }; + + const isSelectedColumnJsonbType = columns.find((col) => col.accessor === column)?.dataType === 'jsonb'; + return ( ); }; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx index 922608fb74..a8c37767d8 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinConstraint.jsx @@ -14,6 +14,8 @@ import { getPrivateRoute } from '@/_helpers/routes'; import { useNavigate } from 'react-router-dom'; import useConfirm from './Confirm'; import { deepClone } from '@/_helpers/utilities/utils.helpers'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { ToolTip } from '@/_components'; const JoinConstraint = ({ darkMode, index, onRemove, onChange, data }) => { const { selectedTableId, tables, joinOptions, findTableDetails, tableForeignKeyInfo } = @@ -406,7 +408,11 @@ const JoinOn = ({ const rightFieldTableDetails = (rightFieldTable && findTableDetails(rightFieldTable)) || {}; const leftFieldOptions = leftFieldTableDetails?.table_name - ? tableInfo[leftFieldTableDetails.table_name]?.map((col) => ({ label: col.Header, value: col.Header })) ?? [] + ? tableInfo[leftFieldTableDetails.table_name]?.map((col) => ({ + label: col.Header, + value: col.Header, + icon: col.dataType, + })) ?? [] : []; const selectedLeftField = leftFieldTableDetails?.table_name ? tableInfo[leftFieldTableDetails.table_name]?.find((col) => col.Header === leftFieldColumn) ?? [] @@ -420,9 +426,17 @@ const JoinOn = ({ } return true; }) - .map((col) => ({ label: col.Header, value: col.Header })) || [] + .map((col) => ({ + label: col.Header, + value: col.Header, + icon: col.dataType, + })) || [] : []; + const selectedRightField = rightFieldTableDetails?.table_name + ? tableInfo[rightFieldTableDetails.table_name]?.find((col) => col.Header === rightFieldColumn) ?? [] + : {}; + const _operators = [{ label: '=', value: '=' }]; const groupOperators = [ @@ -480,7 +494,7 @@ const JoinOn = ({ + {selectedLeftField?.dataType === 'jsonb' && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + onChange && + onChange({ + ...condition, + leftField: { + ...condition.leftField, + jsonpath: value, + }, + }); + }} + enablePreview={false} + height="30" + placeholder="->>key" + componentName={condition?.leftField?.columnName ? `{}${condition.leftField.columnName}` : ''} + /> + + +
+ )} - + {/* +
{operator}
+ {selectedRightField?.dataType === 'jsonb' && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + onChange && + onChange({ + ...condition, + rightField: { + ...condition.rightField, + jsonpath: value, + }, + }); + }} + enablePreview={false} + height="30" + placeholder="->>key" + componentName={condition?.rightField?.columnName ? `{}${condition.rightField.columnName}` : ''} + /> + + +
+ )}
{index > 0 && ( { + const selectedJsonColumns = [...joinSelectOptions]; + const indexToBeChanged = selectedJsonColumns.findIndex((col) => col.name === colName && col.table === table); + if (indexToBeChanged !== -1) { + selectedJsonColumns[indexToBeChanged] = { ...selectedJsonColumns[indexToBeChanged], jsonpath: value }; + } + setJoinSelectOptions(selectedJsonColumns); + }; + return ( - + {tables.length ? ( tables.map((table) => { const respectiveTableSelectedOptions = joinSelectOptions.filter((val) => val?.table === table); const respectiveTableOptions = tableOptions[table] ?? []; + + const tableDetails = findTableDetails(table); + + const allOptionOfTableWithDataType = []; + + const tableJsonbColumntypes = tableInfo[tableDetails?.table_name]?.reduce((acc, col) => { + if (col?.dataType === 'jsonb') { + acc.push(col.accessor); + allOptionOfTableWithDataType.push({ label: col.accessor, value: col.accessor, icon: col.dataType }); + } else { + allOptionOfTableWithDataType.push({ label: col.accessor, value: col.accessor, icon: col.dataType }); + } + return acc; + }, []); + + const selectedJsonbColumns = respectiveTableSelectedOptions?.filter((col) => + tableJsonbColumntypes?.includes(col.name) + ); + return ( - - -
- {findTableDetails(table)?.table_name ?? ''} -
- - - { - const aChecked = joinSelectOptions.some((item) => item.name === a.value && item.table === table); - const bChecked = joinSelectOptions.some((item) => item.name === b.value && item.table === table); - if (aChecked && !bChecked) { - return -1; - } - if (!aChecked && bChecked) { - return 1; - } - return 0; - }) ?? []), - ]} - darkMode={darkMode} - isMulti - onChange={(values) => handleChange(values, table)} - value={[ - ...(respectiveTableOptions?.length === respectiveTableSelectedOptions?.length && - respectiveTableSelectedOptions?.length !== 0 - ? [ - { - label: 'Select All', - value: 'SELECT ALL', - }, - ] - : []), - ...respectiveTableSelectedOptions.map((column) => ({ value: column?.name, label: column?.name })), - ]} - /> - -
+
+ + +
+ {findTableDetails(table)?.table_name ?? ''} +
+ + + { + const aChecked = joinSelectOptions.some( + (item) => item.name === a.value && item.table === table + ); + const bChecked = joinSelectOptions.some( + (item) => item.name === b.value && item.table === table + ); + if (aChecked && !bChecked) { + return -1; + } + if (!aChecked && bChecked) { + return 1; + } + return 0; + }) ?? []), + ]} + darkMode={darkMode} + isMulti + onChange={(values) => handleChange(values, table)} + value={[ + ...(respectiveTableOptions?.length === respectiveTableSelectedOptions?.length && + respectiveTableSelectedOptions?.length !== 0 + ? [ + { + label: 'Select All', + value: 'SELECT ALL', + }, + ] + : []), + ...respectiveTableSelectedOptions.map((column) => ({ value: column?.name, label: column?.name })), + ]} + /> + +
+ + +
); }) ) : ( @@ -159,3 +210,177 @@ export default function JoinSelect({ darkMode }) {
); } + +const JsonBfieldsForSelect = ({ selectedJsonbColumns, handleJSonChange, table }) => { + const [jsonPaths, setJsonPaths] = useState({}); + + const isInitialized = useRef(false); + + useEffect(() => { + // Check if selectedJsonbColumns has data and if initialization has not already occurred + if (!isInitialized.current && selectedJsonbColumns?.length > 0) { + const jsonPathsToUpdate = selectedJsonbColumns.reduce((acc, col) => { + const uuid = uuidv4(); + acc[uuid] = { + name: col.name, + jsonpath: col?.jsonpath || '', + id: uuid, + table: col.table, + }; + return acc; + }, {}); + setJsonPaths(jsonPathsToUpdate); + isInitialized.current = true; // Prevent further re-runs + } + }, [selectedJsonbColumns]); // Dependency array to track changes + + const handleRemove = (id, colName, colTable) => { + const jsonpathsToUpdate = { ...jsonPaths }; + delete jsonpathsToUpdate[id]; + handleJSonChange('', colName, colTable); + setJsonPaths(jsonpathsToUpdate); + }; + + const handleColumnChange = (id, selectedOption) => { + const jsonpathsToUpdate = { ...jsonPaths }; + jsonpathsToUpdate[id] = { ...jsonpathsToUpdate[id], name: selectedOption.value }; + setJsonPaths(jsonpathsToUpdate); + handleJSonChange(jsonpathsToUpdate[id].jsonpath, jsonpathsToUpdate[id].name, jsonpathsToUpdate[id].table); + }; + + const addNewColumnOptionsPair = () => { + const jsonpathsToUpdate = { ...jsonPaths }; + const uuid = uuidv4(); + jsonpathsToUpdate[uuid] = { + name: '', + jsonpath: '', + id: uuid, + table: table, + }; + setJsonPaths(jsonpathsToUpdate); + }; + + const handleJSonPathChange = (value, colName, tableId, id) => { + const jsonpathsToUpdate = { ...jsonPaths }; + jsonpathsToUpdate[id] = { ...jsonpathsToUpdate[id], jsonpath: value }; + handleJSonChange(value, colName, tableId); + }; + const preSelectedOptions = Object.values(jsonPaths).map((col) => col.name); + + const options = selectedJsonbColumns + .filter((col) => !preSelectedOptions.includes(col.name)) // Filter out columns + .map((col) => ({ label: col.name, value: col.name, table: col.table, icon: 'jsonb' })); // Transform each filtered column + + const isJsonbColumnSelected = _.isEmpty(selectedJsonbColumns); + + return ( +
+
+ {isJsonbColumnSelected ? ( + + ) : ( + + )} + Access nested JSON field + + + + + +
+ {!isJsonbColumnSelected && ( +
+ {Object.entries(jsonPaths).map(([key, colDetails]) => { + return ( +
+ + + handleColumnChange(colDetails.id, selectedOption)} + // darkMode={darkMode} + buttonClasses="border border-end-0 rounded-start overflow-hidden" + /> + + + for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > +
+ { + handleJSonPathChange(value, colDetails.name, colDetails.table, colDetails.id); + }} + enablePreview={false} + height="30" + placeholder="->>'key'" + componentName={colDetails?.name ? `{}${colDetails?.name}` : ''} + /> +
+
+ handleRemove(colDetails.id, colDetails.name, colDetails.table)} + > + + + +
+
+ ); + })} + {_.isEmpty(jsonPaths) && } + + + + + + +   Add column + + +
+ )} +
+ ); +}; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx index 87a321cbb2..2d5a2de518 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/JoinSort.jsx @@ -7,6 +7,8 @@ import Trash from '@/_ui/Icon/solidIcons/Trash'; import AddRectangle from '@/_ui/Icon/bulkIcons/AddRectangle'; import { isEmpty } from 'lodash'; import { NoCondition } from './NoConditionUI'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { ToolTip } from '@/_components'; export default function JoinSort({ darkMode }) { const { tableInfo, joinOrderByOptions, setJoinOrderByOptions, joinOptions, findTableDetails } = @@ -41,6 +43,7 @@ export default function JoinSort({ darkMode }) { label: columns.Header, value: columns.Header + '_' + tableId, table: tableId, + icon: columns.dataType, })) || [], }; tableList.push(tableDetailsForDropDown); @@ -59,11 +62,16 @@ export default function JoinSort({ darkMode }) { ) : ( joinOrderByOptions.map((options, i) => { const tableDetails = options?.table ? findTableDetails(options?.table) : ''; + const isColumnJsonbType = + tableInfo[tableDetails?.table_name]?.find((col) => col.accessor === options?.columnName).dataType === + 'jsonb'; return ( + {isColumnJsonbType && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + setJoinOrderByOptions( + joinOrderByOptions.map((sortBy, index) => { + if (i === index) { + return { + ...sortBy, + jsonpath: value, + }; + } + return sortBy; + }) + ); + }} + enablePreview={false} + height="30" + placeholder="->>'key'" + componentName={options?.columnName ? `{}${options.columnName}` : ''} + /> + + +
+ )}
{ return ( @@ -319,6 +320,23 @@ const RenderFilterSection = ({ darkMode }) => { }), operator: valueToUpdate.operator, }; + case 'Jsonpath': { + return valueToUpdate.isLeftSideCondition + ? { + ...conditionDetail, + leftField: { + ...conditionDetail.leftField, + jsonpath: valueToUpdate.jsonpath, + }, + } + : { + ...conditionDetail, + rightField: { + ...conditionDetail.rightField, + jsonpath: valueToUpdate.jsonpath, + }, + }; + } default: return conditionDetail; } @@ -362,6 +380,8 @@ const RenderFilterSection = ({ darkMode }) => { label: columns.Header, value: columns.Header + '-' + tableId, table: tableId, + icon: columns?.dataType, + // columnDataType: columns?.dataType, })) || [], }; tableList.push(tableDetailsForDropDown); @@ -376,6 +396,10 @@ const RenderFilterSection = ({ darkMode }) => { const filterComponents = conditionsList.map((conditionDetail, index) => { const { operator = '', leftField = {}, rightField = {} } = conditionDetail; const LeftSideTableDetails = leftField?.table ? findTableDetails(leftField?.table) : ''; + const isSelectedColumnJsonb = + leftField?.table && + tableInfo[LeftSideTableDetails?.table_name]?.find((col) => col.accessor === leftField?.columnName)?.dataType === + 'jsonb'; return ( @@ -406,7 +430,7 @@ const RenderFilterSection = ({ darkMode }) => { borderRadius: 0, height: '30px', }} - className="tj-small-btn px-2 rounded-start border border-end-0" + className="tj-small-btn px-2 rounded-start border" > {conditions?.operator}
@@ -414,7 +438,7 @@ const RenderFilterSection = ({ darkMode }) => { updateFilterConditionEntry('Column', index, { @@ -433,10 +457,43 @@ const RenderFilterSection = ({ darkMode }) => { options={tableList} darkMode={darkMode} /> + {isSelectedColumnJsonb && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + updateFilterConditionEntry('Jsonpath', index, { + jsonpath: value, + isLeftSideCondition: true, + }); + }} + enablePreview={false} + height="30" + placeholder="->>'key'" + componentName={leftField?.columnName ? `{}${leftField.columnName}` : ''} + /> + + +
+ )} updateFilterConditionEntry('Operator', index, { operator: change?.value })} value={filterOperatorOptions.find((op) => op.value === operator)} diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ListRows.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ListRows.jsx index 9c85bd99c8..9dd3656bd0 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ListRows.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ListRows.jsx @@ -211,6 +211,7 @@ const RenderSortFields = ({ columns, updateSortOptionsChanged, darkMode, + jsonpath = '', }) => { const orders = [ { value: 'asc', label: 'Ascending' }, @@ -220,9 +221,10 @@ const RenderSortFields = ({ order = orders.find((val) => val.value === order); const existingColumnOptions = Object.values(listRowsOptions?.order_filters).map((item) => item.column); - let displayColumns = columns.map(({ accessor }) => ({ + let displayColumns = columns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); if (existingColumnOptions.length > 0) { @@ -232,13 +234,25 @@ const RenderSortFields = ({ } const handleColumnChange = (selectedOption) => { - updateSortOptionsChanged({ ...listRowsOptions?.order_filters[id], ...{ column: selectedOption.value } }); + updateSortOptionsChanged({ + ...listRowsOptions?.order_filters[id], + ...{ column: selectedOption.value }, + }); }; const handleDirectionChange = (selectedOption) => { updateSortOptionsChanged({ ...listRowsOptions?.order_filters[id], ...{ order: selectedOption.value } }); }; + const handleJsonPathChange = (value) => { + updateSortOptionsChanged({ + ...listRowsOptions?.order_filters[id], + jsonpath: value, + }); + }; + + const isSelectedColumnJsonbType = columns.find((col) => col.accessor === column)?.dataType === 'jsonb'; + return ( ); }; @@ -264,16 +281,20 @@ const RenderFilterFields = ({ updateFilterOptionsChanged, removeFilterConditionPair, darkMode, + jsonpath = '', }) => { - let displayColumns = columns.map(({ accessor }) => ({ + let displayColumns = columns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); - console.log('manish ---> inside list', { operator, operators }); operator = operators.find((val) => val.value === operator); const handleColumnChange = (selectedOption) => { - updateFilterOptionsChanged({ ...listRowsOptions?.where_filters[id], ...{ column: selectedOption.value } }); + updateFilterOptionsChanged({ + ...listRowsOptions?.where_filters[id], + ...{ column: selectedOption.value }, + }); }; const handleOperatorChange = (selectedOption) => { @@ -284,6 +305,15 @@ const RenderFilterFields = ({ updateFilterOptionsChanged({ ...listRowsOptions?.where_filters[id], ...{ value: newValue } }); }; + const handleJsonPathChange = (value) => { + updateFilterOptionsChanged({ + ...listRowsOptions?.where_filters[id], + jsonpath: value, + }); + }; + + const isSelectedColumnJsonbType = columns.find((col) => col.accessor === column)?.dataType === 'jsonb'; + return ( ); }; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx index f0dfbbb5de..57b39f111a 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderColumnUI.jsx @@ -1,4 +1,5 @@ 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'; @@ -14,12 +15,14 @@ const RenderColumnUI = ({ handleValueChange, removeColumnOptionsPair, id, + currentColumnType = '', }) => { column = typeof column === 'object' && column !== null ? column : { label: column, value: column }; + const isJSonTypeColumn = currentColumnType === 'jsonb'; return ( -
+
- + handleValueChange(newValue)} + onChange={(newValue) => { + if (isJSonTypeColumn) { + const [_, __, resolvedValue] = resolveReferences(`{{${newValue}}}`); + handleValueChange(resolvedValue); + } else { + handleValueChange(newValue); + } + }} + {...(isJSonTypeColumn && { lang: 'javascript' })} /> + {isJSonTypeColumn && Use SQL mode to update values in nested JSON field }
); diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderFilterSectionUI.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderFilterSectionUI.jsx index ddf60d64ec..983019697a 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderFilterSectionUI.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderFilterSectionUI.jsx @@ -1,4 +1,5 @@ import CodeHinter from '@/AppBuilder/CodeEditor'; +import { ToolTip } from '@/_components'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import Trash from '@/_ui/Icon/solidIcons/Trash'; import React from 'react'; @@ -18,6 +19,9 @@ const RenderFilterSectionUI = ({ handleValueChange, removeFilterConditionPair, id, + isSelectedColumnJsonbType = false, + handleJsonPathChange, + jsonpath = '', }) => { column = typeof column === 'object' && column !== null ? column : { label: column, value: column }; operator = typeof operator === 'object' && operator !== null ? operator : { label: operator, value: operator }; @@ -34,11 +38,40 @@ const RenderFilterSectionUI = ({ options={displayColumns} onChange={handleColumnChange} // width={'auto'} - buttonClasses="border border-end-0 rounded-start overflow-hidden" + buttonClasses={`border ${ + isSelectedColumnJsonbType ? 'border-top-left-rounded' : 'rounded-start' + } overflow-hidden`} showPlaceHolder darkMode={darkMode} isMulti={false} /> + {isSelectedColumnJsonbType && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + handleJsonPathChange(value); + }} + enablePreview={false} + height="30" + placeholder="->>key" + /> + + +
+ )} @@ -49,7 +82,7 @@ const RenderFilterSectionUI = ({ options={operators} onChange={handleOperatorChange} // width={'auto'} - buttonClasses="border border-end-0 overflow-hidden" + buttonClasses="border border-start-0 border-end-0 overflow-hidden" showPlaceHolder darkMode={darkMode} /> diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderSortUI.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderSortUI.jsx index cc1d4cb9ad..b5021974ba 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderSortUI.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/RenderSortUI.jsx @@ -1,3 +1,5 @@ +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { ToolTip } from '@/_components'; import { ButtonSolid } from '@/_ui/AppButton/AppButton'; import Trash from '@/_ui/Icon/solidIcons/Trash'; import React from 'react'; @@ -14,6 +16,9 @@ const RenderSortUI = ({ handleDirectionChange, removeSortConditionPair, id, + isSelectedColumnJsonbType = false, + handleJsonPathChange, + jsonpath = '', }) => { column = typeof column === 'object' && column !== null ? column : { label: column, value: column }; order = typeof order === 'object' && order !== null ? order : { label: order, value: order }; @@ -24,7 +29,9 @@ const RenderSortUI = ({ + {isSelectedColumnJsonbType && ( +
+ for JSON object and ->> for text' + } + tooltipClassName="tjdb-table-tooltip" + placement="top" + trigger={['hover', 'focus']} + width="160px" + > + + { + handleJsonPathChange(value); + }} + enablePreview={false} + height="30" + placeholder="->>key" + /> + + +
+ )}
+ ))} { const { columns, updateRowsOptions, handleUpdateRowsOptionsChange } = useContext(TooljetDatabaseContext); @@ -118,7 +119,11 @@ export const UpdateRows = React.memo(({ darkMode }) => { -
+
{isEmpty(updateRowsOptions?.columns) && } {!isEmpty(updateRowsOptions?.columns) && Object.entries(updateRowsOptions?.columns).map(([key, value]) => { @@ -142,7 +147,7 @@ export const UpdateRows = React.memo(({ darkMode }) => { variant="ghostBlue" size="sm" onClick={addNewColumnOptionsPair} - className={`cursor-pointer fit-content ${isEmpty(updateRowsOptions?.columns) ? '' : 'mt-2'}`} + className="d-flex justify-content-start width-fit-content cursor-pointer" > { - let displayColumns = columns.map(({ accessor }) => ({ + let displayColumns = columns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); operator = operators.find((val) => val.value === operator); const handleColumnChange = (selectedOption) => { - updateFilterOptionsChanged({ ...updateRowsOptions?.where_filters[id], ...{ column: selectedOption.value } }); + updateFilterOptionsChanged({ + ...updateRowsOptions?.where_filters[id], + ...{ column: selectedOption.value }, + }); }; const handleOperatorChange = (selectedOption) => { @@ -189,6 +199,15 @@ const RenderFilterFields = ({ updateFilterOptionsChanged({ ...updateRowsOptions?.where_filters[id], ...{ value: newValue } }); }; + const handleJsonPathChange = (value) => { + updateFilterOptionsChanged({ + ...updateRowsOptions?.where_filters[id], + jsonpath: value, + }); + }; + + const isSelectedColumnJsonbType = columns.find((col) => col.accessor === column)?.dataType === 'jsonb'; + return ( ); }; @@ -217,13 +239,19 @@ const RenderColumnOptions = ({ darkMode, removeColumnOptionsPair, }) => { - const filteredColumns = columns.filter(({ column_default }) => !column_default?.startsWith('nextval(')); + const filteredColumns = columns.filter(({ column_default }) => + _.isObject(column_default) ? true : !column_default?.startsWith('nextval(') + ); + const existingColumnOptions = Object.values(updateRowsOptions?.columns).map(({ column }) => column); - let displayColumns = filteredColumns.map(({ accessor }) => ({ + let displayColumns = filteredColumns.map(({ accessor, dataType }) => ({ value: accessor, label: accessor, + icon: dataType, })); + const currentColumnType = columns?.find((columnDetails) => columnDetails.accessor === column)?.dataType; + if (existingColumnOptions.length > 0) { displayColumns = displayColumns.filter( ({ value }) => !existingColumnOptions.map((item) => item !== column && item).includes(value) @@ -238,7 +266,6 @@ const RenderColumnOptions = ({ }; const newColumnOptions = { ...columnOptions, [id]: updatedOption }; - handleColumnOptionChange(newColumnOptions); }; @@ -264,6 +291,7 @@ const RenderColumnOptions = ({ handleValueChange={handleValueChange} removeColumnOptionsPair={removeColumnOptionsPair} id={id} + currentColumnType={currentColumnType} /> ); }; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/styles.scss b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/styles.scss index e6221ad55e..96d39b1a9c 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/styles.scss +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/styles.scss @@ -30,4 +30,29 @@ font-weight: 500; line-height: 20px; } - } \ No newline at end of file + } + +.tjdb-codehinter-border-none{ + .cm-editor{ + border: none !important; + } +} +.tjdb-codehinter-jsonpath{ + .cm-editor{ + border-radius: 0 0 4px 4px !important; + border-top: 0 !important; + height: 30px ; + min-height: 30px; + } +} + +.border-top-left-rounded{ + border-top-left-radius: 4px; + +} +.border-top-right-rounded{ + border-top-right-radius: 4px; +} +.custom-gap-6{ + gap: 6px; +} \ No newline at end of file diff --git a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx index f3d3bf3796..92772ffea1 100644 --- a/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx +++ b/frontend/src/Editor/QueryManager/QueryEditors/TooljetDatabase/SelectBox.jsx @@ -447,7 +447,8 @@ function DataSourceSelect({ show={ (foreignKeyAccess && props.data.dataType === 'serial') || props.data.dataType === 'boolean' || - props.data.dataType === 'timestamp with time zone' + props.data.dataType === 'timestamp with time zone' || + props.data.dataType === 'jsonb' } >
timeZonesWithOffsets(), []); @@ -240,6 +243,37 @@ const ColumnForm = ({ setOnDeletePopup(true); }; + const [disabledSaveButton, setDisabledSaveButton] = useState(true); + + useEffect(() => { + setDisabledSaveButton(columnName === ''); + }, [columnName]); + + const handleInputError = (bool = false) => { + setDisabledSaveButton(bool); + }; + + const codehinterCallback = React.useCallback(() => { + return ( + { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + setDefaultValue(resolvedValue); + }} + componentName={`{} ${columnName}`} + errorCallback={handleInputError} + lineNumbers={false} + placeholder="{}" + columnName={columnName} + showErrorMessage={true} + /> + ); + }, [defaultValue]); + return (
@@ -326,6 +360,10 @@ const ColumnForm = ({ isClearable={true} isPlaceholderEnabled={true} /> + ) : isJsonbColumnType ? ( +
e.stopPropagation()}> + {codehinterCallback()} +
) : !foreignKeyDetails?.length > 0 && !isForeignKey ? ( ) : null}
-
@@ -425,7 +464,7 @@ const ColumnForm = ({ disabled={ isEmpty(dataType) || isEmpty(columnName) || - ['serial', 'boolean', 'timestamp with time zone'].includes(dataType?.value) + ['serial', 'boolean', 'timestamp with time zone', 'jsonb'].includes(dataType?.value) } /> @@ -460,7 +499,6 @@ const ColumnForm = ({ )} */}
- -
- {!['boolean', 'timestamp with time zone'].includes(dataType?.value) && ( -
+
+
-
-

{'UNIQUE'}

-

- This constraint restricts entry of duplicate values in this column. -

-
+
+
+

{'UNIQUE'}

+

+ This constraint restricts entry of duplicate values in this column. +

- )} +
0 && isEmpty(defaultValue) && dataType?.value !== 'serial') + (isNotNull === true && rows.length > 0 && isEmpty(defaultValue) && dataType?.value !== 'serial') || + disabledSaveButton } showToolTipForFkOnReadDocsSection={true} initiator={initiator} diff --git a/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx b/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx index 5255d5d859..aa5041955f 100644 --- a/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/ColumnsForm.jsx @@ -22,6 +22,7 @@ const ColumnsForm = ({ createForeignKeyInEdit = false, selectedTable, setForeignKeys, + handleInputError, }) => { const [columnSelection, setColumnSelection] = useState({ index: 0, value: '', configurations: {} }); const [hoveredColumn, setHoveredColumn] = useState(null); @@ -117,6 +118,7 @@ const ColumnsForm = ({ indexHover={hoveredColumn} foreignKeyDetails={foreignKeyDetails} existingForeignKeyDetails={existingForeignKeyDetails} // foreignKeys from context state + handleInputError={handleInputError} />
diff --git a/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx b/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx index 582b808f15..3943964a16 100644 --- a/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/EditColumnForm.jsx @@ -29,6 +29,8 @@ import Skeleton from 'react-loading-skeleton'; import Tick from '@/_ui/Icon/bulkIcons/Tick'; import DateTimePicker from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker'; import { getLocalTimeZone, timeZonesWithOffsets } from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/util'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; const ColumnForm = ({ onClose, @@ -75,6 +77,7 @@ const ColumnForm = ({ const [onDelete, setOnDelete] = useState([]); const [onUpdate, setOnUpdate] = useState([]); const isTimestamp = dataType === 'timestamp with time zone'; + const isJsonbColumnType = dataType === 'jsonb'; const { Option } = components; // this is for DropDownDetails component which is react select @@ -462,6 +465,37 @@ const ColumnForm = ({ return matchingColumn; } + const [disabledSaveButton, setDisabledSaveButton] = useState(true); + + useEffect(() => { + setDisabledSaveButton(columnName === ''); + }, [columnName]); + + const handleInputError = (bool = false) => { + setDisabledSaveButton(bool); + }; + + const codehinterCallback = React.useCallback(() => { + return ( + { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + setDefaultValue(resolvedValue); + }} + componentName={`{} ${columnName}`} + errorCallback={handleInputError} + lineNumbers={false} + placeholder="{}" + columnName={columnName} + showErrorMessage={true} + /> + ); + }, [defaultValue]); + return ( <>
@@ -586,7 +620,6 @@ const ColumnForm = ({ />
)} -
Default value @@ -606,6 +639,10 @@ const ColumnForm = ({ isClearable={true} isPlaceholderEnabled={true} /> + ) : isJsonbColumnType ? ( +
e.stopPropagation()}> + {codehinterCallback()} +
) : !isMatchingForeignKeyColumn(selectedColumn?.Header) ? ( ) : null}
- {/* foreign key toggle */} -
@@ -721,7 +758,7 @@ const ColumnForm = ({ dataType?.value === 'serial' || isEmpty(dataType) || isEmpty(columnName) || - ['boolean', 'serial', 'timestamp with time zone'].includes(dataType) + ['boolean', 'serial', 'timestamp with time zone', 'jsonb'].includes(dataType) } /> @@ -799,9 +836,7 @@ const ColumnForm = ({ initiator="ForeignKeyTableForm" /> - {/* */} -
- {!['boolean', 'timestamp with time zone'].includes(dataType) && ( - -
-
- -
-
-

{'UNIQUE'}

-

- This constraint restricts entry of duplicate values in this column. -

-
+ selectedColumn.constraints_type.is_primary_key === true) + ? 'Serial data type value must be unique' + : selectedColumn.dataType === 'boolean' + ? 'Unique constraint cannot be added for boolean type column' + : selectedColumn.dataType === 'timestamp with time zone' + ? 'Unique constraint cannot be added for this type column' + : selectedColumn.dataType === 'jsonb' + ? 'Unique constraint cannot be added for JSON type column' + : null + } + placement="top" + tooltipClassName="tooltip-table-edit-column" + style={toolTipPlacementStyle} + show={ + selectedColumn.constraints_type?.is_primary_key === true || + (selectedColumn.dataType === 'serial' && + (selectedColumn.constraints_type.is_primary_key !== true || + selectedColumn.constraints_type.is_primary_key === true)) || + ['boolean', 'timestamp with time zone', 'jsonb'].includes(selectedColumn.dataType) + } + > +
+
+
- - )} +
+

{'UNIQUE'}

+

+ This constraint restricts entry of duplicate values in this column. +

+
+
+
diff --git a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx index 8e40a57066..963e30093d 100644 --- a/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/EditRowForm.jsx @@ -20,6 +20,33 @@ import ArrowRight from '../Icons/ArrowRight.svg'; import Skeleton from 'react-loading-skeleton'; import DateTimePicker from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker'; import { getLocalTimeZone, getUTCOffset } from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/util'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; + +const compareValueInObject = (currentValue, defaultValue) => { + try { + let cv = currentValue; + let defaultVal = defaultValue; + + // Step 1: Parse cv until it's fully converted to an object + while (typeof cv === 'string') { + cv = JSON.parse(cv); + } + + // Step 2: Use Lodash's isEqual for a deep comparison + return _.isEqual(cv, defaultVal); + } catch (error) { + return false; + } +}; + +const transformJSONValue = (value) => { + if (typeof value === 'string') { + return JSON.stringify(JSON.parse(value)); + } else { + return JSON.stringify(value); + } +}; const EditRowForm = ({ onEdit, @@ -39,6 +66,12 @@ const EditRowForm = ({ const [inputValues, setInputValues] = useState([]); const [errorMap, setErrorMap] = useState({}); + const [disabledSaveButton, setDisabledSaveButton] = useState(false); + + const handleInputError = (bool = false) => { + setDisabledSaveButton(bool); + }; + useEffect(() => { toast.dismiss(); }, []); @@ -46,9 +79,17 @@ const EditRowForm = ({ useEffect(() => { if (currentValue) { const keysWithNullValues = Object.keys(currentValue).filter((key) => currentValue[key] === null); - const keysWithDefaultValues = Object.keys(currentValue).filter( - (key, index) => currentValue[key]?.toString() === columns[index].column_default - ); + const keysWithDefaultValues = Object.keys(currentValue).filter((key, index) => { + if (columns[index].dataType === 'jsonb') { + try { + return compareValueInObject(currentValue[key], columns[index].column_default); + } catch (error) { + return false; + } + } + return currentValue[key]?.toString() === columns[index].column_default; + }); + setActiveTab((prevActiveTabs) => { const newActiveTabs = [...prevActiveTabs]; keysWithNullValues.forEach((key) => { @@ -57,20 +98,36 @@ const EditRowForm = ({ newActiveTabs[index] = 'Null'; } }); + keysWithDefaultValues.forEach((key) => { const index = Object.keys(currentValue).indexOf(key); - if (currentValue[key]?.toString() === columns[index].column_default) { + const compareCondition = + columns[index].dataType === 'jsonb' + ? compareValueInObject(currentValue[key], columns[index].column_default) + : currentValue[key]?.toString() === columns[index].column_default; + if (compareCondition) { newActiveTabs[index] = 'Default'; } }); return newActiveTabs; }); + const initialInputValues = currentValue ? Object.keys(currentValue).map((key, index) => { - const value = - currentValue[key] === null ? null : currentValue[key] === currentValue[key] ? currentValue[key] : ''; + const isJsonDataType = columns[index].dataType === 'jsonb'; + let isJsonbCurrentAndDefaultValueEqual = false; + if (isJsonDataType) { + isJsonbCurrentAndDefaultValueEqual = compareValueInObject( + currentValue[key], + columns[index].column_default + ); + } + const value = currentValue[key] === null ? null : currentValue[key] ? currentValue[key] : ''; const disabledValue = - currentValue[key] === null || currentValue[key]?.toString() === columns[index].column_default + currentValue[key] === null || + (isJsonDataType + ? isJsonbCurrentAndDefaultValueEqual + : currentValue[key]?.toString() === columns[index].column_default) ? true : false; return { value: value, disabled: disabledValue, label: value }; @@ -136,12 +193,18 @@ const EditRowForm = ({ newInputValues[index] = { value: defaultValue, disabled: true, label: defaultValue }; } else if (defaultValue && tabData === 'Default' && dataType === 'boolean') { newInputValues[index] = { value: actualDefaultVal, disabled: true, label: actualDefaultVal }; + } else if (defaultValue && tabData === 'Default' && dataType === 'jsonb') { + const [_, __, resolvedValue] = resolveReferences(`{{${defaultValue}}}`); + newInputValues[index] = { value: resolvedValue, disabled: false, label: resolvedValue }; } else if (nullValue && tabData === 'Null' && dataType !== 'boolean') { newInputValues[index] = { value: null, disabled: true, label: null }; } else if (nullValue && tabData === 'Null' && dataType === 'boolean') { newInputValues[index] = { value: null, disabled: true, label: null }; - } else if (tabData === 'Custom' && customVal.length > 0) { + } else if (tabData === 'Custom' && customVal?.length > 0 && dataType !== 'jsonb') { newInputValues[index] = { value: customVal, disabled: false, label: customVal }; + } else if (tabData === 'Custom' && customVal?.length > 0 && dataType === 'jsonb') { + const [_, __, resolvedValue] = resolveReferences(`{{${customVal}}}`); + newInputValues[index] = { value: resolvedValue, disabled: false, label: resolvedValue }; } else if (tabData === 'Custom' && customVal.length <= 0) { newInputValues[index] = { value: '', disabled: false, label: '' }; } else { @@ -165,6 +228,20 @@ const EditRowForm = ({ ? null : null, }); + } else if (dataType === 'jsonb') { + setRowData({ + ...rowData, + [columnName]: + newInputValues[index].value === null + ? null + : compareValueInObject(newInputValues[index].value, defaultValue) + ? defaultValue + : _.isEqual(newInputValues[index].value, currentValue) + ? currentValue + : currentValue === null && customVal === '' + ? '' + : null, + }); } else { setRowData({ ...rowData, @@ -451,6 +528,108 @@ const EditRowForm = ({ )}
); + case 'jsonb': + return ( +
e.stopPropagation()}> + {inputValues[index]?.value === null ? ( +
+ + Null + +
+ ) : activeTab[index] === 'Default' ? ( +
+ {transformJSONValue(column_default)} +
+ ) : ( +
e.stopPropagation()}> + { + if (value === 'Null') { + handleInputChange(index, value, columnName); + } else { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + handleInputChange(index, resolvedValue, columnName); + } + }} + componentName={`{} ${columnName}`} + errorCallback={handleInputError} + lineNumbers={false} + placeholder="{}" + columnName={columnName} + showErrorMessage={true} + /> +
+ )} + + {(inputValues[index]?.disabled || shouldInputBeDisabled) && ( +
{ + handleDisabledInputClick( + index, + 'Custom', + column_default, + isNullable, + columnName, + dataType, + currentValue[columnName] + ); + handleTabClick(index, 'Custom', column_default, isNullable, columnName, dataType); + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 4, + cursor: 'pointer', + backgroundColor: 'transparent', + }} + /> + )} +
+ ); default: break; @@ -724,7 +903,9 @@ const EditRowForm = ({ fetching={fetching} onClose={onClose} onEdit={handleSubmit} - shouldDisableCreateBtn={Object.values(matchingObject).includes('') || (isSubset && isSubsetForCharacter)} + shouldDisableCreateBtn={ + Object.values(matchingObject).includes('') || (isSubset && isSubsetForCharacter) || disabledSaveButton + } initiator={initiator} /> )} diff --git a/frontend/src/TooljetDatabase/Forms/RowForm.jsx b/frontend/src/TooljetDatabase/Forms/RowForm.jsx index 799c99fd54..3b59c5bce5 100644 --- a/frontend/src/TooljetDatabase/Forms/RowForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/RowForm.jsx @@ -15,7 +15,34 @@ import './styles.scss'; import Skeleton from 'react-loading-skeleton'; import DateTimePicker from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker'; import { getLocalTimeZone, getUTCOffset } from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/util'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; +import _ from 'lodash'; +const compareValueInObject = (currentValue, defaultValue) => { + try { + let cv = currentValue; + let defaultVal = defaultValue; + + // Step 1: Parse cv until it's fully converted to an object + while (typeof cv === 'string') { + cv = JSON.parse(cv); + } + + // Step 2: Use Lodash's isEqual for a deep comparison + return _.isEqual(cv, defaultVal); + } catch (error) { + return false; + } +}; + +const transformJSONValue = (value) => { + if (typeof value === 'string') { + return JSON.stringify(JSON.parse(value)); + } else { + return JSON.stringify(value); + } +}; const RowForm = ({ onCreate, onClose, @@ -30,6 +57,13 @@ const RowForm = ({ const inputRefs = useRef({}); const primaryKeyColumns = []; const nonPrimaryKeyColumns = []; + + const [disabledSaveButton, setDisabledSaveButton] = useState(false); + + const handleInputError = (bool = false) => { + setDisabledSaveButton(bool); + }; + columns.forEach((column) => { if (column?.constraints_type?.is_primary_key) { primaryKeyColumns.push({ ...column }); @@ -121,7 +155,8 @@ const RowForm = ({ return matchingColumn; } - const handleDisabledInputClick = (index, columnName) => { + const handleDisabledInputClick = (index, columnName, defaultValue = '', nullValue = '', dataType = '') => { + //index, columnName, 'Custom', defaultValue, null, dataType if (inputRefs.current[columnName]) { setTimeout(() => { inputRefs.current[columnName].focus(); @@ -137,6 +172,7 @@ const RowForm = ({ if (isCurrentlyDisabled) { setData((prevData) => ({ ...prevData, [columnName]: newInputValues[index].value })); } + handleTabClick(index, 'Custom', defaultValue, nullValue, columnName, dataType); }; const handleTabClick = (index, tabData, defaultValue, nullValue, columnName, dataType) => { @@ -155,6 +191,9 @@ const RowForm = ({ disabled: true, label: defaultValue, }; + } else if (defaultValue && tabData === 'Default' && dataType === 'jsonb') { + const [_, __, resolvedValue] = resolveReferences(`{{${defaultValue}}}`); + newInputValues[index] = { value: resolvedValue, disabled: false, label: resolvedValue }; } else if (nullValue && tabData === 'Null' && dataType !== 'boolean') { newInputValues[index] = { value: null, checkboxValue: false, disabled: true, label: null }; } else if (nullValue && tabData === 'Null' && dataType === 'boolean') { @@ -164,6 +203,8 @@ const RowForm = ({ } else if (tabData === 'Custom' && dataType === 'timestamp with time zone') { if (oldActiveTab[index] === 'Custom') return; newInputValues[index] = { value: new Date().toISOString(), checkboxValue: false, disabled: false, label: '' }; + } else if (tabData === 'Custom' && dataType === 'jsonb') { + newInputValues[index] = { value: '', checkboxValue: false, disabled: false, label: '' }; } else { newInputValues[index] = { value: '', checkboxValue: false, disabled: false, label: '' }; } @@ -177,6 +218,16 @@ const RowForm = ({ ...data, [accessor]: inputValuesArr[index].checkboxValue === null ? null : inputValuesArr[index].checkboxValue, }); + } else if (dataType === 'jsonb') { + setData({ + ...data, + [accessor]: + inputValuesArr[index].value === null + ? null + : compareValueInObject(inputValuesArr[index].value, defaultVal) + ? defaultVal + : inputValuesArr[index].value, + }); } else { setData({ ...data, @@ -194,13 +245,13 @@ const RowForm = ({ const newInputValues = [...inputValues]; const isNull = value === null || value === 'Null'; newInputValues[index] = { - value: value === 'Null' ? null : value, + value: isNull ? null : value, checkboxValue: inputValues[index].checkboxValue, disabled: isNull, - label: value === 'Null' ? null : value, + label: isNull ? null : value, }; setInputValues(newInputValues); - setData({ ...data, [columnName]: value === 'Null' ? null : value }); + setData({ ...data, [columnName]: isNull ? null : value }); if (isNull) { const newActiveTabs = [...activeTab]; newActiveTabs[index] = 'Null'; @@ -416,7 +467,7 @@ const RowForm = ({ )} {inputValues[index]?.disabled && (
handleDisabledInputClick(index, columnName)} + onClick={() => handleDisabledInputClick(index, columnName, defaultValue, isNullable, dataType)} style={{ position: 'absolute', top: 0, @@ -479,7 +530,7 @@ const RowForm = ({ /> {inputValues[index]?.disabled && (
handleDisabledInputClick(index, columnName)} + onClick={() => handleDisabledInputClick(index, columnName, defaultValue, isNullable, dataType)} style={{ position: 'absolute', top: 0, @@ -495,6 +546,100 @@ const RowForm = ({
); + case 'jsonb': { + return ( +
+ {inputValues[index]?.value === null ? ( +
+ + Null + +
+ ) : activeTab[index] === 'Default' ? ( +
+ {transformJSONValue(defaultValue)} +
+ ) : ( +
e.stopPropagation()}> + { + if (value === 'Null') { + handleInputChange(index, value, columnName); + } else { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + handleInputChange(index, resolvedValue, columnName); + } + }} + componentName={`{} ${columnName}`} + errorCallback={handleInputError} + lineNumbers={false} + placeholder="{}" + columnName={columnName} + showErrorMessage={true} + /> +
+ )} + + {inputValues[index]?.disabled && ( +
{ + handleDisabledInputClick(index, columnName, defaultValue, isNullable, dataType); + }} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 1, + cursor: 'pointer', + backgroundColor: 'transparent', + }} + /> + )} +
+ ); + } default: break; } @@ -686,7 +831,7 @@ const RowForm = ({ fetching={fetching} onClose={onClose} onCreate={handleSubmit} - shouldDisableCreateBtn={Object.values(matchingObject).includes('')} + shouldDisableCreateBtn={Object.values(matchingObject).includes('') || disabledSaveButton} initiator={initiator} />
diff --git a/frontend/src/TooljetDatabase/Forms/TableForm.jsx b/frontend/src/TooljetDatabase/Forms/TableForm.jsx index 178d456bc4..987c282e14 100644 --- a/frontend/src/TooljetDatabase/Forms/TableForm.jsx +++ b/frontend/src/TooljetDatabase/Forms/TableForm.jsx @@ -35,6 +35,12 @@ const TableForm = ({ const selectedTableColumnDetails = Object.values(selectedTableColumns); const darkMode = localStorage.getItem('darkMode') === 'true'; + //Following state and handleInputError is to disable footer if JSON value is invalid for JSON column type + const [disabledCreateButton, setDisabledCreateButton] = useState(false); + const handleInputError = (bool = false) => { + setDisabledCreateButton(bool); + }; + const [fetching, setFetching] = useState(false); const [showModal, setShowModal] = useState(false); const [createForeignKeyInEdit, setCreateForeignKeyInEdit] = useState(false); @@ -165,6 +171,10 @@ const TableForm = ({ toast.error('Column names cannot be empty'); return; } + if (disabledCreateButton) { + toast.error('Invalid JSON syntax for JSONB type column'); + return; + } const checkingValues = isEmpty(foreignKeyDetails) ? false : true; @@ -190,6 +200,11 @@ const TableForm = ({ const handleEdit = async () => { if (!validateTableName()) return; + if (disabledCreateButton) { + toast.error('Invalid JSON syntax for JSONB type column'); + return; + } + setFetching(true); const { error } = await tooljetDatabaseService.renameTable( organizationId, @@ -319,6 +334,7 @@ const TableForm = ({ createForeignKeyInEdit={createForeignKeyInEdit} selectedTable={selectedTable} setForeignKeys={setForeignKeys} + handleInputError={handleInputError} />
{ + return ( + { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + setColumns((prevColumns) => { + console.log('prevCol', { prevColumns }); + const updatedColumns = { ...prevColumns }; + updatedColumns[index] = { + ...updatedColumns[index], // Preserve other properties like `column_name` + column_default: resolvedValue, + }; + return updatedColumns; + }); + }} + componentName={`{} ${columnDetails[index].column_name}`} + errorCallback={handleInputError} + lineNumbers={false} + placeholder="{}" + height="36" + columnName={columnDetails[index]?.column_name} + /> + ); +}, areEqual); function TableSchema({ columns, @@ -35,6 +71,7 @@ function TableSchema({ foreignKeyDetails, setForeignKeyDetails, existingForeignKeyDetails, + handleInputError, }) { const [referencedColumnDetails, setReferencedColumnDetails] = useState([]); const [previousColumnNames, setPreviousColumnNames] = useState([]); @@ -155,452 +192,482 @@ function TableSchema({ return (
- {Object.keys(columnDetails).map((index) => ( -
-
- {/*
+ {Object.keys(columnDetails).map((index) => { + return ( +
+
+ {/*
*/} -
- { - e.persist(); - const prevColumns = { ...columnDetails }; - setForeignKeyDetails((prevState) => { - return prevState.map((item) => { - return { - ...item, - column_names: item.column_names.map((col) => { - return col === previousColumnNames[index] ? e.target.value : col; - }), - }; +
+ { + e.persist(); + const prevColumns = _.cloneDeep(columnDetails); + setForeignKeyDetails((prevState) => { + return prevState.map((item) => { + return { + ...item, + column_names: item.column_names.map((col) => { + return col === previousColumnNames[index] ? e.target.value : col; + }), + }; + }); }); - }); - prevColumns[index].column_name = e.target.value; - setColumns(prevColumns); - }} - value={columnDetails[index].column_name} - type="text" - className="form-control" - placeholder="Enter name" - data-cy={`name-input-field-${columnDetails[index].column_name}`} - // disabled={columns[index]?.constraints_type?.is_primary_key === true} - /> -
- - item.column_names[0] === columnDetails[index]?.column_name) ? ( -
- Foreign key relation -
- - { - foreignKeyDetails.find((item) => item.column_names[0] === columnDetails[index]?.column_name) - ?.column_names[0] - } - - - {`${ - foreignKeyDetails.find((item) => item.column_names[0] === columnDetails[index]?.column_name) - ?.referenced_table_name - }.${ - foreignKeyDetails.find((item) => item.column_names[0] === columnDetails[index]?.column_name) - ?.referenced_column_names[0] - }`} -
-
- ) : columnDetails[index]?.data_type === 'boolean' ? ( - 'Foreign key relation cannot be created for boolean type column' - ) : columnDetails[index]?.data_type === 'serial' ? ( - 'Foreign key relation cannot be created for serial type column' - ) : ( - 'No foreign key relation' - ) - } - placement="top" - tooltipClassName="tootip-table" - > -
item.column_names[0] === columnDetails[index]?.column_name - ), - 'foreign-key-relation': foreignKeyDetails?.some( - (item) => item.column_names[0] !== columnDetails[index]?.column_name - ), - })} - > - -
-
- - -
- { - e.persist(); - const prevColumns = { ...columnDetails }; - prevColumns[index].column_default = e.target.value; - setColumns(prevColumns); - }} - value={ - columnDetails[index].data_type === 'serial' - ? 'Auto-generated' - : // : checkDefaultValue(columnDetails[index].column_default) - // ? null - columnDetails[index].column_default - } - type="text" - className="form-control defaultValue" - data-cy="default-input-field" - placeholder={ - (columnDetails[index].data_type === 'serial' && - columnDetails[index]?.constraints_type?.is_primary_key === true) || - columnDetails[index].data_type === 'serial' - ? 'Auto-generated' - : 'Enter value' - } - disabled={ - (columnDetails[index].data_type === 'serial' && - columnDetails[index]?.constraints_type?.is_primary_key === true) || - columnDetails[index].data_type === 'serial' - } - /> - )} +
+ { + e.persist(); + const prevColumns = { ...columnDetails }; + prevColumns[index].column_default = e.target.value; + setColumns(prevColumns); + }} + value={ + columnDetails[index].data_type === 'serial' + ? 'Auto-generated' + : // : checkDefaultValue(columnDetails[index].column_default) + // ? null + columnDetails[index].data_type === 'jsonb' + ? columnDetails[index].constraints_type?.is_not_null + ? JSON.stringify({}) + : null + : columnDetails[index].column_default + } + type="text" + className="form-control defaultValue" + data-cy="default-input-field" + placeholder={ + (columnDetails[index].data_type === 'serial' && + columnDetails[index]?.constraints_type?.is_primary_key === true) || + columnDetails[index].data_type === 'serial' + ? 'Auto-generated' + : 'Enter value' + } + disabled={ + (columnDetails[index].data_type === 'serial' && + columnDetails[index]?.constraints_type?.is_primary_key === true) || + columnDetails[index].data_type === 'serial' + } + /> + )} +
+ + )} - -
-
-
- ))} + ); + })}
); } diff --git a/frontend/src/TooljetDatabase/Forms/styles.scss b/frontend/src/TooljetDatabase/Forms/styles.scss index 0339eb6132..bbea1465af 100644 --- a/frontend/src/TooljetDatabase/Forms/styles.scss +++ b/frontend/src/TooljetDatabase/Forms/styles.scss @@ -778,6 +778,57 @@ max-width: 100%; } +.tjdb-codehinter-wrapper-drawer{ + .cm-editor{ + max-height: 36px !important; + height: 36px !important; + &.cm-focused{ + outline: none !important; + background: var(--indigo2) !important; + border: 1px solid var(--indigo9) !important; + } + .cm-content{ + display: flex; + align-items: center; + font-size: 12px; + } + } + .tjdb-hinter-error{ + .cm-focused{ + outline: none !important; + border:1px solid red !important; + } + } +} +// added this to border when codehinter is open in the portal, if any error is there +.tjdb-hinter-error{ + .cm-focused{ + outline: none !important; + border:1px solid red !important; + } +} +.tjdb-codehinter-wrapper-drawer-tableSchema{ + .cm-editor{ + max-height: 36px !important; + min-height: 36px !important; + &.cm-focused{ + outline: none !important; + border: 1px solid !important; + border-color: #90b5e2 !important; + } + .cm-content{ + display: flex; + align-items: center; + font-size: 12px; + } + } + .tjdb-hinter-error{ + .cm-editor{ + outline: none !important; + border:1px solid red !important; + } + } +} .width-lg{ width: 181.5px; } diff --git a/frontend/src/TooljetDatabase/Icons/JSONB.svg b/frontend/src/TooljetDatabase/Icons/JSONB.svg new file mode 100644 index 0000000000..04e9da4079 --- /dev/null +++ b/frontend/src/TooljetDatabase/Icons/JSONB.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/TooljetDatabase/Icons/Jsonb.svg b/frontend/src/TooljetDatabase/Icons/Jsonb.svg new file mode 100644 index 0000000000..04e9da4079 --- /dev/null +++ b/frontend/src/TooljetDatabase/Icons/Jsonb.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/TooljetDatabase/Menu/CellEditMenu/CellHinterWrapper.jsx b/frontend/src/TooljetDatabase/Menu/CellEditMenu/CellHinterWrapper.jsx new file mode 100644 index 0000000000..c11782a668 --- /dev/null +++ b/frontend/src/TooljetDatabase/Menu/CellEditMenu/CellHinterWrapper.jsx @@ -0,0 +1,265 @@ +import React, { useRef, useState } from 'react'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/AppBuilder/CodeEditor/utils'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import cx from 'classnames'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import { Popover } from 'react-bootstrap'; +import _ from 'lodash'; + +const transformvalue = (value = '') => { + if (typeof value !== 'string') { + return JSON.stringify(value); + } + return value; +}; + +export const CellHinterWrapper = ({ + isNotNull, + defaultValue, + selectedValue, + setSelectedValue, + saveFunction, + isEditCell, + columnDetails, + close, + closePopover, + show, + previousCellValue, +}) => { + const initialValueRef = useRef(selectedValue); + + const defaultValueRef = useRef(defaultValue); + + const [shouldUpdateToDefaultVal, setShouldUpdateDefaultVal] = useState(false); + + const [shouldUpdateToNullVal, setShouldUpdateNullVal] = useState(false); + + const [disabledSaveButton, setDisabledSaveButton] = useState(false); + + const handleInputError = (bool = false) => { + setDisabledSaveButton(bool); + }; + + const handleKeyDown = (e) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + const event = new Event('click', { bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + } + }; + const darkMode = localStorage.getItem('darkMode') === 'true'; + + const tranformedValue = (rawValue) => { + if (!rawValue) { + return JSON.stringify(rawValue); + } + const [_, __, resolvedValue] = resolveReferences(`{{${rawValue}}}`); + return resolvedValue; + }; + + const SaveChangesSection = () => { + const handleNullToggle = (value) => { + setShouldUpdateNullVal(false); + + if (value) { + setSelectedValue(null); + shouldUpdateToDefaultVal && setShouldUpdateDefaultVal(false); + } else { + setSelectedValue(tranformedValue(previousCellValue)); + } + setShouldUpdateNullVal(value); + }; + + const handleDefaultToggle = (value) => { + setShouldUpdateDefaultVal(false); + + if (value) { + setSelectedValue(defaultValue); + shouldUpdateToNullVal && setShouldUpdateNullVal(false); + defaultValueRef.current = defaultValue; + } else { + const val = tranformedValue(previousCellValue); + defaultValueRef.current = val; + setSelectedValue(val); + } + setShouldUpdateDefaultVal(true); + }; + + return ( +
+
+ { +
+
+ +
+
Press Enter to go on next line
+
+ } +
+
+ Esc +
+
Discard Changes
+
+
+
+ {isNotNull === false && ( +
+
+ + Set to null + +
+
+ +
+
+ )} + + {defaultValue !== null && ( +
+
+ + Set to default + +
+
+ +
+
+ )} +
+
+ ); + }; + + const handleCancel = () => { + const event = new Event('click', { bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + setShouldUpdateDefaultVal(false); + setShouldUpdateNullVal(false); + }; + + const handleSave = (e) => { + if (e) { + e.stopPropagation(); + } + saveFunction(selectedValue); + const event = new Event('click', { bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + setShouldUpdateDefaultVal(false); + setShouldUpdateNullVal(false); + defaultValueRef.current = defaultValue; + }; + + const SaveChangesFooter = () => { + return ( +
+
+ + Cancel + + handleSave(e)} + disabled={disabledSaveButton} + variant="primary" + size="sm" + className="fs-12 p-2" + > + Save + +
+
+ ); + }; + const popover = ( + + {disabledSaveButton && ( + +
+ + {' '} + + + Invalid JSON syntax +
+
+ )} + e.stopPropagation()}> +
+ + +
+
+
+ ); + + const customFooter = () => { + return ( +
e.stopPropagation()} + > + {disabledSaveButton && ( +
+
+ + {' '} + + + Invalid JSON syntax +
+
+ )} +
+ + +
+
+ ); + }; + + return ( + +
+ { + const [_, __, resolvedValue] = resolveReferences(`{{${value}}}`); + setSelectedValue(resolvedValue); + }} + enablePreview={false} + footerComponent={customFooter} + componentName={`{} ${columnDetails.Header}`} + errorCallback={handleInputError} + defaultValue={defaultValueRef.current} + reset={shouldUpdateToDefaultVal} + shouldUpdateToNullVal={shouldUpdateToNullVal} + columnName={columnDetails?.Header} + /> +
+
+ ); +}; diff --git a/frontend/src/TooljetDatabase/Menu/CellEditMenu/index.jsx b/frontend/src/TooljetDatabase/Menu/CellEditMenu/index.jsx index 68aa6da3dd..4afd83db28 100644 --- a/frontend/src/TooljetDatabase/Menu/CellEditMenu/index.jsx +++ b/frontend/src/TooljetDatabase/Menu/CellEditMenu/index.jsx @@ -14,6 +14,7 @@ import Skeleton from 'react-loading-skeleton'; import DateTimePicker from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/DateTimePicker'; import { TooljetDatabaseContext } from '@/TooljetDatabase'; import { getLocalTimeZone } from '@/Editor/QueryManager/QueryEditors/TooljetDatabase/util'; +import { CellHinterWrapper } from './CellHinterWrapper'; export const CellEditMenu = ({ darkMode = false, @@ -335,9 +336,7 @@ export const CellEditMenu = ({ )}
)} - {!isBoolean && } - {/* Footer */}
@@ -346,7 +345,14 @@ export const CellEditMenu = ({ ); return ( - + {isForeignKey ? ( + ) : dataType === 'jsonb' ? ( +
+ +
) : ( + //
children )} diff --git a/frontend/src/TooljetDatabase/Menu/CellEditMenu/styles.scss b/frontend/src/TooljetDatabase/Menu/CellEditMenu/styles.scss index 33902d3d45..d2e8f53689 100644 --- a/frontend/src/TooljetDatabase/Menu/CellEditMenu/styles.scss +++ b/frontend/src/TooljetDatabase/Menu/CellEditMenu/styles.scss @@ -3,6 +3,9 @@ height: auto; margin-top: 2px; inset: 0px auto auto -9px !important; + &.jsonb-popover{ + min-width: 350px; + } .tjdb-bool-cell-menu-badge-default { padding: 2px 12px; @@ -67,4 +70,54 @@ } +} + +.portal-container:has(.tjdb-dashboard-codehinter){ + .cm-editor{ + border-radius: 0 !important; + } + .tjdb-dashboard-codehinter{ + border: 1px solid var(--borders-disabled-on-white, #E4E7EB) ; + border-width: 0px 1px 1px 1px !important; + border-radius: 0px 0px 5px 5px; + } +} +.tjdb-dashboard-codehinter-wrapper-cell{ + .cm-editor{ + max-height: 32px; + border: 0; + } +} + +.tjdb-dashboard-codehinter-wrapper-cell{ + // display: none !important; + .footer-component{ + display: none !important; + } + .tjdb-hinter-error{ + .cm-theme{ + border: 0 !important; + } + } +} +/* Apply a border to .tjdb-selected-cell if it contains both required nested elements */ +.tjdb-selected-cell:has(.tjdb-dashboard-codehinter-wrapper-cell .tjdb-hinter-error) { + border: 1px solid red !important; /* Add your desired border style */ +} + +.tjdb-dashboard-codehinter.custom-footer { + background-color: var(--base); + border: 1px solid var(--slate5); +} +.tjdb-table-cell-edit-popover,.tjdb-dashboard-codehinter.custom-footer{ + .tjdb-cell-hinter-invalid-syntax-header{ + background-color: var(--tomato3) !important; + color: var(--tomato9) !important; + font-size: 12px !important; + padding: 5px; + } + .main-body{ + padding: 10px; + } + } \ No newline at end of file diff --git a/frontend/src/TooljetDatabase/Table/ActionsPopover/UniqueConstraintPopOver.jsx b/frontend/src/TooljetDatabase/Table/ActionsPopover/UniqueConstraintPopOver.jsx index 6c518a9889..cada3e658c 100644 --- a/frontend/src/TooljetDatabase/Table/ActionsPopover/UniqueConstraintPopOver.jsx +++ b/frontend/src/TooljetDatabase/Table/ActionsPopover/UniqueConstraintPopOver.jsx @@ -126,6 +126,8 @@ export const UniqueConstraintPopOver = ({ ? 'Boolean data type cannot be unique' : columns[index]?.data_type === 'timestamp with time zone' ? 'Unique constraint cannot be added to this column type' + : columns[index]?.data_type === 'jsonb' + ? 'JSON cannot cannot have unique constraint' : null } placement="top" @@ -133,7 +135,7 @@ export const UniqueConstraintPopOver = ({ style={toolTipPlacementStyle} show={ columns[index]?.constraints_type?.is_primary_key === true || - ['boolean', 'timestamp with time zone'].includes(columns[index]?.data_type) + ['boolean', 'jsonb', 'timestamp with time zone'].includes(columns[index]?.data_type) } >
@@ -163,7 +165,7 @@ export const UniqueConstraintPopOver = ({ }} disabled={ columns[index]?.constraints_type?.is_primary_key === true || - ['boolean', 'timestamp with time zone'].includes(columns[index]?.data_type) + ['boolean', 'jsonb', 'timestamp with time zone'].includes(columns[index]?.data_type) } /> diff --git a/frontend/src/TooljetDatabase/Table/Header.jsx b/frontend/src/TooljetDatabase/Table/Header.jsx index 33f21ee2c1..ddb293e334 100644 --- a/frontend/src/TooljetDatabase/Table/Header.jsx +++ b/frontend/src/TooljetDatabase/Table/Header.jsx @@ -149,7 +149,7 @@ const Header = ({
-
+
<> {(isDirectRowExpand || Object.keys(selectedRowIds).length === 0) && ( <> diff --git a/frontend/src/TooljetDatabase/Table/index.jsx b/frontend/src/TooljetDatabase/Table/index.jsx index a9221f2e6a..5d98ce70fe 100644 --- a/frontend/src/TooljetDatabase/Table/index.jsx +++ b/frontend/src/TooljetDatabase/Table/index.jsx @@ -419,10 +419,7 @@ const Table = ({ collapseSidebar }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditRowDrawerOpen]); - const tableData = React.useMemo( - () => (loading ? Array(10).fill({}) : selectedTableData), - [loading, selectedTableData] - ); + const [tableColumnTypes, setTableColumnTypes] = React.useState({}); const tableColumns = React.useMemo(() => { if (loading) { @@ -433,17 +430,39 @@ const Table = ({ collapseSidebar }) => { } else { const primaryKeyArray = []; const nonPrimaryKeyArray = []; + const updatedColumnTypes = {}; + columns.forEach((column) => { if (column.constraints_type.is_primary_key) { primaryKeyArray.push({ ...column }); } else { nonPrimaryKeyArray.push({ ...column }); } + updatedColumnTypes[column.accessor] = column.dataType; }); + setTableColumnTypes(updatedColumnTypes); + return [...primaryKeyArray, ...nonPrimaryKeyArray]; } }, [loading, columns]); + const tableData = React.useMemo( + () => + loading + ? Array(10).fill({}) + : selectedTableData.map((data) => { + return Object.entries(data).reduce((accumulator, [key, value]) => { + if (tableColumnTypes?.[key] === 'jsonb' && value !== null) { + accumulator[key] = JSON.stringify(value); + } else { + accumulator[key] = value; + } + return accumulator; + }, {}); + }), + [loading, selectedTableData] + ); + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable( { columns: tableColumns, @@ -906,7 +925,7 @@ const Table = ({ collapseSidebar }) => { setEditPopover(false); previousValue === null ? setNullValue(true) : setNullValue(false); setCellVal(previousValue); - document.getElementById('edit-input-blur').blur(); + document.getElementById('edit-input-blur')?.blur(); }; function shouldOpenCellEditMenu(cellColumnIndex) { @@ -1365,7 +1384,11 @@ const Table = ({ collapseSidebar }) => { cell.value, getConfigurationProperty(cell.column.Header, 'timezone', getLocalTimeZone()) ) - : cell.value, + : cell.column.dataType === 'jsonb' && + typeof cell?.value !== 'string' && + cell?.value !== null + ? JSON.stringify(cell?.value) + : cell?.value, index )} placement="bottom" @@ -1403,10 +1426,16 @@ const Table = ({ collapseSidebar }) => { cellClick.cellIndex === index ? ( closeEditPopover(cell.value, index)} + close={() => { + closeEditPopover(cell.value, index); + }} columnDetails={headerGroups[0].headers[index]} saveFunction={(newValue) => { handleToggleCellEdit(newValue, row.values.id, index, rIndex, false, cell.value); + // if (cell.column?.dataType === 'jsonb') { + // const event = new Event('click', { bubbles: true, cancelable: true }); + // document.body.dispatchEvent(event); + // } }} setCellValue={setCellVal} cellValue={cellVal} @@ -1498,6 +1527,16 @@ const Table = ({ collapseSidebar }) => { */}
) : ( + // : cell.column?.dataType === 'jsonb' ? ( + // + // )
{ <> {cell.value === null ? ( Null + ) : cell.column.dataType === 'jsonb' ? ( + `{...}` ) : cell.column.dataType === 'boolean' ? ( //
diff --git a/frontend/src/TooljetDatabase/Table/styles.scss b/frontend/src/TooljetDatabase/Table/styles.scss index a1a29276d0..b63d7615b2 100644 --- a/frontend/src/TooljetDatabase/Table/styles.scss +++ b/frontend/src/TooljetDatabase/Table/styles.scss @@ -168,6 +168,19 @@ width: 80%; } } + &:has(.tjdb-dashboard-codehinter-wrapper-cell){ + padding: 0 !important; + } + + .tjdb-dashboard-codehinter-wrapper-cell{ + .cm-editor{ + border: 0 !important; + } + .codehinter-input.focused .cm-editor{ + border: 0 !important; + } + + } } .tjdb-selected-cell { diff --git a/frontend/src/TooljetDatabase/constants.js b/frontend/src/TooljetDatabase/constants.js index 10e8ffaaab..2eb3c073ba 100644 --- a/frontend/src/TooljetDatabase/constants.js +++ b/frontend/src/TooljetDatabase/constants.js @@ -8,6 +8,7 @@ import Serial from './Icons/Serial.svg'; import ArrowRight from './Icons/ArrowRight.svg'; import RightFlex from './Icons/Right-flex.svg'; import Datetime from './Icons/Datetime.svg'; +import Jsonb from './Icons/Jsonb.svg'; export const dataTypes = [ { @@ -16,6 +17,12 @@ export const dataTypes = [ icon: , value: 'character varying', }, + { + name: 'JSON data type', + label: 'jsonb', + icon: , + value: 'jsonb', + }, { name: 'Integers up to 4 bytes', label: 'int', icon: , value: 'integer' }, { name: 'Integers up to 8 bytes', label: 'bigint', icon: , value: 'bigint' }, { name: 'Decimal numbers', label: 'float', icon: , value: 'double precision' }, @@ -32,6 +39,7 @@ export const dataTypes = [ icon: , value: 'timestamp with time zone', }, + // { name: 'Binary JSON data', label: 'jsonb', icon: , value: 'jsonb' }, ]; export const serialDataType = [ @@ -312,7 +320,7 @@ export default function tjdbDropdownStyles( ...base, width: dropdownContainerWidth, background: darkMode ? 'rgb(31,40,55)' : 'white', - zIndex: 10, + zIndex: 10001, }), singleValue: (provided) => ({ ...provided, @@ -348,6 +356,8 @@ export const renderDatatypeIcon = (type) => { return ; case 'timestamp with time zone': return ; + case 'jsonb': + return ; default: return type; } diff --git a/frontend/src/_components/Portal/Portal.jsx b/frontend/src/_components/Portal/Portal.jsx index 61e0ddc1db..0e332e2e9c 100644 --- a/frontend/src/_components/Portal/Portal.jsx +++ b/frontend/src/_components/Portal/Portal.jsx @@ -35,7 +35,7 @@ const Portal = ({ children, ...restProps }) => { }; return ( - +
classList.push(item)); + classList.forEach((item) => el.classList.add(item)); + target.appendChild(el); return () => { el.remove(); diff --git a/frontend/src/_styles/tabler.scss b/frontend/src/_styles/tabler.scss index 9629d17941..6507a349b2 100644 --- a/frontend/src/_styles/tabler.scss +++ b/frontend/src/_styles/tabler.scss @@ -7644,13 +7644,29 @@ fieldset:disabled .btn { } .rounded { - border-radius: 4px !important + border-radius: 4px ; } .rounded-0 { border-radius: 0 !important } +.rounded-top-left{ + border-top-left-radius: 4px; +} + +.rounded-top-left-0{ + border-top-left-radius: 0 !important; +} +.rounded-top-right-0{ + border-top-right-radius: 0 !important; +} +.rounded-bottom-left-0{ + border-bottom-left-radius: 0 !important; +} +.rounded-bottom-right-0{ + border-bottom-right-radius: 0 !important; +} .rounded-1 { border-radius: 2px !important } diff --git a/frontend/src/_ui/Icon/solidIcons/BigIntCol.jsx b/frontend/src/_ui/Icon/solidIcons/BigIntCol.jsx new file mode 100644 index 0000000000..f95735ce6f --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/BigIntCol.jsx @@ -0,0 +1,24 @@ +import React from 'react'; + +const BigIntCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + +); + +export default BigIntCol; diff --git a/frontend/src/_ui/Icon/solidIcons/BooleanCol.jsx b/frontend/src/_ui/Icon/solidIcons/BooleanCol.jsx new file mode 100644 index 0000000000..8de12d2963 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/BooleanCol.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const BooleanCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + +); + +export default BooleanCol; diff --git a/frontend/src/_ui/Icon/solidIcons/DatetimeCol.jsx b/frontend/src/_ui/Icon/solidIcons/DatetimeCol.jsx new file mode 100644 index 0000000000..020d7b5f95 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/DatetimeCol.jsx @@ -0,0 +1,30 @@ +import React from 'react'; + +const DatetimeCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + + + +); + +export default DatetimeCol; diff --git a/frontend/src/_ui/Icon/solidIcons/FloatCol.jsx b/frontend/src/_ui/Icon/solidIcons/FloatCol.jsx new file mode 100644 index 0000000000..86e9845df1 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/FloatCol.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const FloatCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + + +); + +export default FloatCol; diff --git a/frontend/src/_ui/Icon/solidIcons/IntegerCol.jsx b/frontend/src/_ui/Icon/solidIcons/IntegerCol.jsx new file mode 100644 index 0000000000..8312c92aea --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/IntegerCol.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const IntegerCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + +); + +export default IntegerCol; diff --git a/frontend/src/_ui/Icon/solidIcons/Jsonb.jsx b/frontend/src/_ui/Icon/solidIcons/Jsonb.jsx new file mode 100644 index 0000000000..6124cf8ad8 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/Jsonb.jsx @@ -0,0 +1,14 @@ +import React from 'react'; + +const Jsonb = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + +); + +export default Jsonb; diff --git a/frontend/src/_ui/Icon/solidIcons/SerialCol.jsx b/frontend/src/_ui/Icon/solidIcons/SerialCol.jsx new file mode 100644 index 0000000000..cee27441dc --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/SerialCol.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const SerialCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + +); + +export default SerialCol; diff --git a/frontend/src/_ui/Icon/solidIcons/VarcharCol.jsx b/frontend/src/_ui/Icon/solidIcons/VarcharCol.jsx new file mode 100644 index 0000000000..da1a7d9618 --- /dev/null +++ b/frontend/src/_ui/Icon/solidIcons/VarcharCol.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const VarcharCol = ({ fill = '#C1C8CD', width = '25', className = '', viewBox = '0 0 25 25' }) => ( + + + + +); + +export default VarcharCol; diff --git a/frontend/src/_ui/Icon/solidIcons/index.js b/frontend/src/_ui/Icon/solidIcons/index.js index d4106ae049..9c84429d09 100644 --- a/frontend/src/_ui/Icon/solidIcons/index.js +++ b/frontend/src/_ui/Icon/solidIcons/index.js @@ -174,6 +174,14 @@ import Search01 from './Search01.jsx'; import ShiftButtonIcon from './ShiftButtonIcon.jsx'; import Unpin01 from './Unpin01.jsx'; import WarningUserNotFound from './WarningUserNotFound.jsx'; +import VarcharCol from './VarcharCol.jsx'; +import Jsonb from './Jsonb.jsx'; +import IntegerCol from './IntegerCol.jsx'; +import BigIntCol from './BigIntCol.jsx'; +import FloatCol from './FloatCol.jsx'; +import BooleanCol from './BooleanCol.jsx'; +import SerialCol from './SerialCol.jsx'; +import DatetimeCol from './DatetimeCol'; import AITag from './AITag.jsx'; import Reset from './Reset.jsx'; @@ -531,6 +539,22 @@ const Icon = (props) => { return ; case 'TriangleDownCenter': return ; + case 'jsonb': + return ; + case 'character varying': + return ; + case 'integer': + return ; + case 'bigint': + return ; + case 'double precision': + return ; + case 'boolean': + return ; + case 'serial': + return ; + case 'timestamp with time zone': + return ; case 'AI-tag': return ; default: diff --git a/server/src/dto/tooljet-db-join.dto.ts b/server/src/dto/tooljet-db-join.dto.ts index 82b82c874b..b79750d50c 100644 --- a/server/src/dto/tooljet-db-join.dto.ts +++ b/server/src/dto/tooljet-db-join.dto.ts @@ -21,6 +21,10 @@ class Field { @IsString() @IsNotEmpty({ message: '::Table names for join not selected' }) table: string; + + @IsString() + @IsOptional() + jsonpath: string; } class Conditions { @@ -50,6 +54,10 @@ class ConditionField { @IsString() @IsOptional() // present only when type is column columnName: string; + + @IsString() + @IsOptional() + jsonpath: string; } class ConditionsList { @@ -113,6 +121,10 @@ class Order { @IsIn(['ASC', 'DESC'], { message: '::Sort direction not selected' }) direction: string; + + @IsString() + @IsOptional() + jsonpath: string; } export class TooljetDbJoinDto { diff --git a/server/src/dto/tooljet-db.dto.ts b/server/src/dto/tooljet-db.dto.ts index 2534b1efee..09dca956b2 100644 --- a/server/src/dto/tooljet-db.dto.ts +++ b/server/src/dto/tooljet-db.dto.ts @@ -19,7 +19,7 @@ import { IsObject, IsIn, } from 'class-validator'; -import { sanitizeInput, formatTimestamp, validateDefaultValue } from 'src/helpers/utils.helper'; +import { sanitizeInput, formatTimestamp, validateDefaultValue, formatJSONB } from 'src/helpers/utils.helper'; export function Match(property: string, validationOptions?: ValidationOptions) { return (object: any, propertyName: string) => { @@ -48,7 +48,7 @@ export class MatchTypeConstraint implements ValidatorConstraintInterface { } matchType(value, relatedType) { - if (relatedType === 'character varying' || relatedType === 'timestamp with time zone') { + if (relatedType === 'character varying' || relatedType === 'timestamp with time zone' || relatedType === 'jsonb') { return typeof value === 'string'; } @@ -197,8 +197,10 @@ export class PostgrestTableColumnDto { @IsOptional() @Transform(({ value, obj }) => { - const sanitizedValue = sanitizeInput(value); + const transformedJsonbData = formatJSONB(value, obj); + const sanitizedValue = sanitizeInput(transformedJsonbData); const transformedData = formatTimestamp(sanitizedValue, obj); + return validateDefaultValue(transformedData, obj); }) @Match('data_type', { @@ -296,7 +298,8 @@ export class EditColumnTableDto { @IsOptional() @Transform(({ value, obj }) => { - const sanitizedValue = sanitizeInput(value); + const transformedJsonbData = formatJSONB(value, obj); + const sanitizedValue = sanitizeInput(transformedJsonbData); const transformedData = formatTimestamp(sanitizedValue, obj); return validateDefaultValue(transformedData, obj); }) diff --git a/server/src/dto/validators/schemas/3.0.3/tooljet_database.json b/server/src/dto/validators/schemas/3.0.3/tooljet_database.json new file mode 100644 index 0000000000..714935cba9 --- /dev/null +++ b/server/src/dto/validators/schemas/3.0.3/tooljet_database.json @@ -0,0 +1,106 @@ +{ + "type": "object", + "required": ["id", "table_name", "schema"], + "properties": { + "id": { + "type": "string" + }, + "table_name": { + "type": "string" + }, + "schema": { + "type": "object", + "required": ["columns", "foreign_keys"], + "properties": { + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["column_name", "data_type", "constraints_type"], + "properties": { + "column_name": { + "type": "string" + }, + "data_type": { + "type": "string" + }, + "column_default": { + "type": ["string", "null", "object", "array"] + }, + "character_maximum_length": { + "type": ["integer", "null"] + }, + "numeric_precision": { + "type": ["integer", "null"] + }, + "constraints_type": { + "type": "object", + "required": ["is_not_null", "is_primary_key", "is_unique"], + "properties": { + "is_not_null": { + "type": "boolean" + }, + "is_primary_key": { + "type": "boolean" + }, + "is_unique": { + "type": "boolean" + } + } + }, + "keytype": { + "type": "string" + } + } + } + }, + "foreign_keys": { + "type": "array", + "items": { + "type": "object", + "required": [ + "referenced_table_name", + "constraint_name", + "column_names", + "referenced_column_names", + "on_update", + "on_delete", + "referenced_table_id" + ], + "properties": { + "referenced_table_name": { + "type": "string" + }, + "constraint_name": { + "type": "string" + }, + "column_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "referenced_column_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "on_update": { + "type": "string", + "enum": ["CASCADE", "RESTRICT", "SET NULL", "NO ACTION"] + }, + "on_delete": { + "type": "string", + "enum": ["CASCADE", "RESTRICT", "SET NULL", "NO ACTION"] + }, + "referenced_table_id": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/server/src/helpers/tooljet_db.helper.ts b/server/src/helpers/tooljet_db.helper.ts index eff4fd9d6c..dc327858c8 100644 --- a/server/src/helpers/tooljet_db.helper.ts +++ b/server/src/helpers/tooljet_db.helper.ts @@ -218,3 +218,19 @@ export function modifyTjdbErrorObject(error) { if (error.detail) error['details'] = error.detail; return error; } + +/** + * Validates the JSONB column value. We only allow valid JSON values to be added in the JSONB column. + * @param jsonbColumnList - jsonb column list + * @param inputValues - Values to be created or updated ( Object ) + * @returns - Column names with invalid JSON data. + */ +export function validateTjdbJSONBColumnInputs(jsonbColumnList: Array, inputValues) { + const inValidValueColumnsList = []; + Object.entries(inputValues).forEach(([key, value]) => { + if (jsonbColumnList.includes(key)) { + if (typeof value !== 'object') inValidValueColumnsList.push(key); + } + }); + return inValidValueColumnsList; +} diff --git a/server/src/helpers/utils.helper.ts b/server/src/helpers/utils.helper.ts index 41dde11284..cb9cadc6f4 100644 --- a/server/src/helpers/utils.helper.ts +++ b/server/src/helpers/utils.helper.ts @@ -68,6 +68,15 @@ export function sanitizeInput(value: string) { }); } +export function isJSONString(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch (e) { + return false; + } +} + export function formatTimestamp(value: any, params: any) { const { data_type } = params; if (data_type === 'timestamp with time zone' && value) { @@ -76,6 +85,45 @@ export function formatTimestamp(value: any, params: any) { return value; } +/** + * Since for JSONB column the default value must be in stringify format, if the input has single quotes we would need to escape the single quotes. + * @param input - Default value of JSONB column. + * @returns - Sanitized input by escaping single quotes in the input. + */ +function escapeSingleQuotesInDefaultValueForJSONB(input) { + if (typeof input === 'string') { + return input.replace(/'/g, "''"); + } else if (input.length && Array.isArray(input)) { + return input.map(escapeSingleQuotesInDefaultValueForJSONB); + } else if (!Array.isArray(input) && typeof input === 'object') { + return Object.fromEntries( + Object.entries(input).map(([key, value]) => [key, escapeSingleQuotesInDefaultValueForJSONB(value)]) + ); + } + return input; +} + +/** + * Formats default value passed to JSONB column into a stringify format. + * @param value Default value for a JSONB column. + * @returns Stringify default value. + */ +export function formatJSONB(value: any, params: any) { + const { data_type } = params; + if (data_type === 'jsonb' && value) { + const jsonString = JSON.stringify(escapeSingleQuotesInDefaultValueForJSONB(value)); + return `'${jsonString}'`; + } + return value; +} + +export function formatJoinsJSONBPath(jsonpath: string): string { + const addedQuotesToColumnName = jsonpath.replace(/(->>|->|'[^']*'|\w+)/g, (match) => { + return /->/.test(match) || /^'.*'$/.test(match) ? match : `'${match}'`; + }); + return addedQuotesToColumnName; +} + export function lowercaseString(value: string) { return value?.toLowerCase()?.trim(); } diff --git a/server/src/modules/tooljet_db/tooljet-db.types.ts b/server/src/modules/tooljet_db/tooljet-db.types.ts index ef2f5e92a2..f72abaeb00 100644 --- a/server/src/modules/tooljet_db/tooljet-db.types.ts +++ b/server/src/modules/tooljet_db/tooljet-db.types.ts @@ -10,6 +10,7 @@ export const TJDB = { double_precision: 'double precision' as const, boolean: 'boolean' as const, timestampz: 'timestamp with time zone' as const, + jsonb: 'jsonb' as const, }; export type TooljetDatabaseDataTypes = (typeof TJDB)[keyof typeof TJDB]; @@ -100,8 +101,8 @@ const errorCodeMapping: Partial = { default: 'Insufficient privilege', }, [PostgresErrorCode.UndefinedFunction]: { - proxy_postgrest: '{{fxName}} - aggregate function requires serial, integer, float or big int column type', - join_tables: '{{fxName}} - aggregate function requires serial, integer, float or big int column type', + // proxy_postgrest: '{{fxName}} - aggregate function requires serial, integer, float or big int column type', + // join_tables: '{{fxName}} - aggregate function requires serial, integer, float or big int column type', }, }; @@ -238,7 +239,8 @@ export class TooljetDatabaseError extends QueryFailedError { const regex = /function (\w+)\(([\w\s]+)\) does not exist/; const matches = regex.exec(errorMessage); const table = this.context.internalTables[0].tableName; - return { table, fxName: matches[1] }; + if (Array.isArray(matches) && matches.length) return { table, fxName: matches[1] }; + return null; }, }; return parsers[this.code]?.() || null; diff --git a/server/src/services/postgrest_proxy.service.ts b/server/src/services/postgrest_proxy.service.ts index 12530b04c1..3b20ed5ba2 100644 --- a/server/src/services/postgrest_proxy.service.ts +++ b/server/src/services/postgrest_proxy.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { isEmpty } from 'lodash'; import { EntityManager, In, QueryFailedError } from 'typeorm'; import { InternalTable } from 'src/entities/internal_table.entity'; @@ -11,13 +11,16 @@ import { ActionTypes, ResourceTypes } from 'src/entities/audit_log.entity'; import { PostgrestError, TooljetDatabaseError, TooljetDbActions } from 'src/modules/tooljet_db/tooljet-db.types'; import { QueryError } from 'src/modules/data_sources/query.errors'; import got from 'got'; +import { TooljetDbService } from './tooljet_db.service'; +import { validateTjdbJSONBColumnInputs } from 'src/helpers/tooljet_db.helper'; @Injectable() export class PostgrestProxyService { constructor( private readonly manager: EntityManager, private readonly configService: ConfigService, - private eventEmitter: EventEmitter2 + private eventEmitter: EventEmitter2, + private tooljetDbService: TooljetDbService ) {} // NOTE: This method forwards request directly to PostgREST Using express middleware @@ -67,9 +70,21 @@ export class PostgrestProxyService { req.headers['tableInfo'] = tableInfo; } + if (['PATCH', 'POST'].includes(req.method)) { + await this.validateJSONBInputs(organizationId, internalTable.tableName, req.body); + } + return this.httpProxy(req, res, next); } + /** + * Handles the TJDB request from Query Builder + * @param url + * @param method + * @param headers + * @param body + * @returns + */ async perform( url: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', @@ -102,6 +117,10 @@ export class PostgrestProxyService { headers['tableinfo'] = tableInfo; } + if (['PATCH', 'POST'].includes(method)) { + await this.validateJSONBInputs(headers['tj-workspace-id'], internalTable.tableName, body); + } + const reqHeaders = { ...headers, Authorization: authToken, @@ -120,7 +139,7 @@ export class PostgrestProxyService { return response.body; } catch (error) { - if (!isEmpty(error.response) && (error.response.statusCode < 200 || error.response.statusCode >= 300)) { + if (!isEmpty(error.response.rawBody) && (error.response.statusCode < 200 || error.response.statusCode >= 300)) { const postgrestResponse = JSON.parse(error.response.rawBody.toString().toString('utf8')); const errorMessage = postgrestResponse.message; const errorContext: { @@ -139,7 +158,7 @@ export class PostgrestProxyService { throw new QueryError(tooljetDbError.toString(), { code: tooljetDbError.code }, {}); } - throw new QueryError('Query could not be completed', error.message, {}); + throw new QueryError('Query could not be completed', error.message, { message: error.message }); } } @@ -237,6 +256,26 @@ export class PostgrestProxyService { throw new NotFoundException('Internal table not found: ' + tableNamesNotInOrg); } + + private async validateJSONBInputs(organizationId, tableName, body) { + const tableDetails = await this.tooljetDbService.perform(organizationId, 'view_table', { + table_name: tableName, + }); + + const jsonbColumns = tableDetails.columns + .filter((column) => column.data_type === 'jsonb') + .map((column) => column.column_name); + + if (jsonbColumns.length) { + const inValidJsonbColumns = validateTjdbJSONBColumnInputs(jsonbColumns, body); + if (inValidJsonbColumns.length) { + throw new HttpException( + `Expected JSON values in the following columns : ${inValidJsonbColumns.join(', ')}`, + 400 + ); + } + } + } } function replaceUrlForPostgrest(url: string) { diff --git a/server/src/services/tooljet_db.service.ts b/server/src/services/tooljet_db.service.ts index 37ff4b56f4..2d08c80803 100644 --- a/server/src/services/tooljet_db.service.ts +++ b/server/src/services/tooljet_db.service.ts @@ -14,7 +14,7 @@ import { InjectEntityManager } from '@nestjs/typeorm'; import { InternalTable } from 'src/entities/internal_table.entity'; import { LicenseService } from '@licensing/service'; import { LICENSE_FIELD, LICENSE_LIMIT, LICENSE_LIMITS_LABEL } from '@licensing/helper'; -import { generatePayloadForLimits } from 'src/helpers/utils.helper'; +import { generatePayloadForLimits, formatJoinsJSONBPath, formatJSONB } from 'src/helpers/utils.helper'; import { isString, isEmpty, camelCase } from 'lodash'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { ActionTypes, ResourceTypes } from 'src/entities/audit_log.entity'; @@ -253,9 +253,16 @@ export class TooljetDbService { c.ORDINAL_POSITION; `); + const transformedColumnDefaultValues = columns.map((column) => { + return { + ...column, + column_default: column.data_type === 'jsonb' ? JSON.parse(column.column_default) : column.column_default, + }; + }); + return { foreign_keys, - columns, + columns: transformedColumnDefaultValues, configurations: internalTable.configurations, }; } @@ -934,7 +941,10 @@ export class TooljetDbService { // Building `SELECT` statement with aliased column names if (!isEmpty(queryJson.fields) && isEmpty(queryJson.aggregates)) { queryJson.fields.forEach((field) => { - const fieldName = `"${internalTableIdToNameMap[field.table]}"."${field.name}"`; + const fieldName = field.jsonpath + ? `"${internalTableIdToNameMap[field.table]}"."${field.name}"${formatJoinsJSONBPath(field.jsonpath)}` + : `"${internalTableIdToNameMap[field.table]}"."${field.name}"`; + const fieldAlias = `${internalTableIdToNameMap[field.table]}_${field.name}`; queryBuilder.addSelect(fieldName, fieldAlias); }); @@ -998,7 +1008,9 @@ export class TooljetDbService { // order by if (queryJson.order_by) { queryJson.order_by.forEach((order) => { - const orderByColumn = `"${internalTableIdToNameMap[order.table]}"."${order.columnName}"`; + const orderByColumn = order.jsonpath + ? `"${internalTableIdToNameMap[order.table]}"."${order.columnName}"${formatJoinsJSONBPath(order.jsonpath)}` + : `"${internalTableIdToNameMap[order.table]}"."${order.columnName}"`; queryBuilder.addOrderBy(orderByColumn, order.direction as 'ASC' | 'DESC'); }); } @@ -1009,6 +1021,7 @@ export class TooljetDbService { return queryBuilder; } + // Param: internalTableIdToNameMap - is the aliases of tablename private constructFilterConditions(conditions, internalTableIdToNameMap) { let conditionString = ''; const conditionParams = {}; @@ -1033,15 +1046,27 @@ export class TooljetDbService { conditions.conditionsList.forEach((condition, index) => { const paramName = `${condition.leftField.columnName}_${index}`; - const leftField = - condition.leftField.type == 'Column' - ? `"${internalTableIdToNameMap[condition.leftField.table]}"."${condition.leftField.columnName}"` - : `${condition.leftField.columnName}`; + let leftField; + if (condition.leftField.type == 'Column') { + leftField = condition.leftField.jsonpath + ? `"${internalTableIdToNameMap[condition.leftField.table]}"."${ + condition.leftField.columnName + }"${formatJoinsJSONBPath(condition.leftField.jsonpath)}` + : `"${internalTableIdToNameMap[condition.leftField.table]}"."${condition.leftField.columnName}"`; + } else { + leftField = `${condition.leftField.columnName}`; + } - const rightField = - condition.rightField.type == 'Column' - ? `"${internalTableIdToNameMap[condition.rightField.table]}"."${condition.rightField.columnName}"` - : maybeParameterizeValue(condition.operator, paramName, condition.rightField.value); + let rightField; + if (condition.rightField.type == 'Column') { + rightField = condition.rightField.jsonpath + ? `"${internalTableIdToNameMap[condition.rightField.table]}"."${ + condition.rightField.columnName + }"${formatJoinsJSONBPath(condition.rightField.jsonpath)}` + : `"${internalTableIdToNameMap[condition.rightField.table]}"."${condition.rightField.columnName}"`; + } else { + rightField = maybeParameterizeValue(condition.operator, paramName, condition.rightField.value); + } conditionString += `${leftField} ${condition.operator} ${rightField}`; @@ -1158,6 +1183,7 @@ export class TooljetDbService { const isSerial = () => data_type === TJDB.integer && /^nextval\(/.test(column_default); const isCharacterVarying = () => data_type === TJDB.character_varying; const isTimestampWithTimeZone = () => data_type === TJDB.timestampz; + const isJSONB = () => data_type === TJDB.jsonb; if (isSerial()) return { data_type: TJDB.serial, column_default: undefined }; if (isCharacterVarying()) @@ -1170,6 +1196,14 @@ export class TooljetDbService { data_type, column_default: this.addQuotesIfMissing(column_default), }; + if (isJSONB()) { + if (typeof column_default === 'object') { + return { + data_type, + column_default: formatJSONB(column_default, { data_type }), + }; + } + } return { data_type, column_default }; }; diff --git a/server/src/services/tooljet_db_bulk_upload.service.ts b/server/src/services/tooljet_db_bulk_upload.service.ts index a99a539a3d..bd01d13d06 100644 --- a/server/src/services/tooljet_db_bulk_upload.service.ts +++ b/server/src/services/tooljet_db_bulk_upload.service.ts @@ -231,6 +231,8 @@ export class TooljetDbBulkUploadService { case TJDB.double_precision: case TJDB.bigint: return this.convertNumber(columnValue, supportedDataType); + case TJDB.jsonb: + return JSON.parse(columnValue); default: return columnValue; } diff --git a/server/src/services/tooljet_db_operations.service.ts b/server/src/services/tooljet_db_operations.service.ts index 0aa062a29b..5eab39bcaa 100644 --- a/server/src/services/tooljet_db_operations.service.ts +++ b/server/src/services/tooljet_db_operations.service.ts @@ -554,14 +554,16 @@ function buildPostgrestQuery(filters) { Object.keys(filters).map((key) => { if (!isEmpty(filters[key])) { - const { column, operator, value, order } = filters[key]; + const { column, operator, value, order, jsonpath = '' } = filters[key]; if (!isEmpty(column) && !isEmpty(order)) { - postgrestQueryBuilder.order(column, order); + const columnName = jsonpath ? `${column}${jsonpath}` : column; + postgrestQueryBuilder.order(columnName, order); } if (!isEmpty(column) && !isEmpty(operator)) { - postgrestQueryBuilder[operator](column, value); + const columnName = jsonpath ? `${column}${jsonpath}` : column; + postgrestQueryBuilder[operator](columnName, value); } } });