diff --git a/.vscode/settings.json b/.vscode/settings.json index e13d54d778..ac6e6079cc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[javascript, typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "eslint.validate": [ "javascript", @@ -8,8 +8,8 @@ "typescript", "typescriptreact" ], - "eslint.format.enable": false, - "editor.formatOnSave": false, + "eslint.format.enable": true, + "editor.formatOnSave": true, "json.schemas": [ { "fileMatch": [ diff --git a/cypress-tests/cypress-platform.config.js b/cypress-tests/cypress-platform.config.js index 6b1954140a..cb3575906a 100644 --- a/cypress-tests/cypress-platform.config.js +++ b/cypress-tests/cypress-platform.config.js @@ -98,8 +98,12 @@ module.exports = defineConfig({ configFile: environment.configFile, specPattern: [ "cypress/e2e/happyPath/platform/firstUser/firstUserOnboarding.cy.js", + // Exclude specific files from ceTestcases/apps and ceTestcases/workspace "cypress/e2e/happyPath/platform/ceTestcases/apps/appSlug.cy.js", - "cypress/e2e/happyPath/platform/ceTestcases/**/!(*appSlug).cy.js", + "cypress/e2e/happyPath/platform/ceTestcases/**/!(*appSlug|appImport|privateAndpublicApps|version).cy.js", + // Exclude workspaceConstants.cy.js explicitly + "cypress/e2e/happyPath/platform/ceTestcases/workspace/!(*groupDuplication|workspaceConstants).cy.js", + "!cypress/e2e/happyPath/platform/ceTestcases/workspace/workspaceConstants.cy.js", "cypress/e2e/happyPath/platform/commonTestcases/**/*.cy.js", ], numTestsKeptInMemory: 1, diff --git a/frontend/assets/translations/en.json b/frontend/assets/translations/en.json index 1feaf4277d..c464a81a65 100644 --- a/frontend/assets/translations/en.json +++ b/frontend/assets/translations/en.json @@ -572,6 +572,7 @@ "styles": "Styles", "general": "General", "validation": "Validation", + "structure": "Structure", "documentation": "Read documentation for {{componentMeta}}", "widgetNameEmptyError": "Widget name cannot be empty", "componentNameExistsError": "Component name already exists", diff --git a/frontend/ee b/frontend/ee index aa52120545..9da4f77691 160000 --- a/frontend/ee +++ b/frontend/ee @@ -1 +1 @@ -Subproject commit aa521205455afd59e85762716a0012c1e44986e1 +Subproject commit 9da4f776915e328120c3024e551ef6b8032f9f63 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43b332e6ea..483cb1dfcf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -63,6 +63,7 @@ "dotenv": "^16.0.3", "draft-js": "^0.11.7", "draft-js-export-html": "^1.4.1", + "draft-js-import-html": "^1.4.1", "driver.js": "^0.9.8", "emoji-mart": "^5.5.2", "file-loader": "^6.2.0", @@ -105,7 +106,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.3.8", "react-highlight-words": "^0.21.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^4.3.5", @@ -16948,6 +16949,31 @@ "immutable": "3.x.x" } }, + "node_modules/draft-js-import-element": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/draft-js-import-element/-/draft-js-import-element-1.4.0.tgz", + "integrity": "sha512-WmYT5PrCm47lGL5FkH6sRO3TTAcn7qNHsD3igiPqLG/RXrqyKrqN4+wBgbcT2lhna/yfWTRtgzAbQsSJoS1Meg==", + "dependencies": { + "draft-js-utils": "^1.4.0", + "synthetic-dom": "^1.4.0" + }, + "peerDependencies": { + "draft-js": ">=0.10.0", + "immutable": "3.x.x" + } + }, + "node_modules/draft-js-import-html": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/draft-js-import-html/-/draft-js-import-html-1.4.1.tgz", + "integrity": "sha512-KOZmtgxZriCDgg5Smr3Y09TjubvXe7rHPy/2fuLSsL+aSzwUDwH/aHDA/k47U+WfpmL4qgyg4oZhqx9TYJV0tg==", + "dependencies": { + "draft-js-import-element": "^1.4.0" + }, + "peerDependencies": { + "draft-js": ">=0.10.0", + "immutable": "3.x.x" + } + }, "node_modules/draft-js-utils": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/draft-js-utils/-/draft-js-utils-1.4.1.tgz", @@ -32645,6 +32671,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/synthetic-dom": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/synthetic-dom/-/synthetic-dom-1.4.0.tgz", + "integrity": "sha512-mHv51ZsmZ+ShT/4s5kg+MGUIhY7Ltq4v03xpN1c8T1Krb5pScsh/lzEjyhrVD0soVDbThbd2e+4dD9vnDG4rhg==" + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 41dbb51e92..4dc4be5289 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -101,7 +101,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.3.8", "react-highlight-words": "^0.21.0", "react-hot-toast": "^2.4.0", "react-hotkeys-hook": "^4.3.5", diff --git a/frontend/src/AppBuilder/AppBuilder.jsx b/frontend/src/AppBuilder/AppBuilder.jsx index 9f7a8f4e2d..fa12e0d1d9 100644 --- a/frontend/src/AppBuilder/AppBuilder.jsx +++ b/frontend/src/AppBuilder/AppBuilder.jsx @@ -14,6 +14,7 @@ import EditorHeader from '@/AppBuilder/Header'; import LeftSidebar from '@/AppBuilder/LeftSidebar'; import Popups from './Popups'; import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext'; +import RightSidebarToggle from '@/AppBuilder/RightSideBar/RightSidebarToggle'; import { shallow } from 'zustand/shallow'; // const EditorHeader = lazy(() => import('@/AppBuilder/Header')); @@ -25,6 +26,7 @@ import { shallow } from 'zustand/shallow'; // TODO: split Loader into separate component and remove editor loading state from Editor export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMode, appType = 'front-end' }) => { useAppData(appId, moduleId, darkMode); + const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen); const isEditorLoading = useStore((state) => state.loaderStore.modules[moduleId].isEditorLoading, shallow); const currentMode = useStore((state) => state.modeStore.modules[moduleId].currentMode, shallow); const isModuleEditor = appType === 'module'; @@ -54,9 +56,10 @@ export const Editor = ({ id: appId, darkMode, moduleId = 'canvas', switchDarkMod {window?.public_config?.ENABLE_MULTIPLAYER_EDITING === 'true' && } - + - + + {isRightSidebarOpen && }{' '} diff --git a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx index 6f8711fe2e..12d33283c8 100644 --- a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx +++ b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Container } from './Container'; import Grid from './Grid'; import { EditorSelecto } from './Selecto'; @@ -17,9 +17,13 @@ import useAppDarkMode from '@/_hooks/useAppDarkMode'; import useAppCanvasMaxWidth from './useAppCanvasMaxWidth'; import { DeleteWidgetConfirmation } from './DeleteWidgetConfirmation'; import useSidebarMargin from './useSidebarMargin'; +import PagesSidebarNavigation from '../RightSideBar/PageSettingsTab/PageMenu/PagesSidebarNavigation'; +import { resolveReferences } from '@/_helpers/utils'; +import useRightSidebarMargin from './userRightSidebarMargin'; +import { DragGhostWidget } from './GhostWidgets'; import AppCanvasBanner from '../../AppBuilder/Header/AppCanvasBanner'; -export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => { +export const AppCanvas = ({ appId, isViewer = false, switchDarkMode, darkMode }) => { const { moduleId, isModuleMode, appType } = useModuleContext(); const canvasContainerRef = useRef(); const handleCanvasContainerMouseUp = useStore((state) => state.handleCanvasContainerMouseUp, shallow); @@ -42,9 +46,33 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => const setIsComponentLayoutReady = useStore((state) => state.setIsComponentLayoutReady, shallow); const canvasMaxWidth = useAppCanvasMaxWidth({ mode: currentMode }); const editorMarginLeft = useSidebarMargin(canvasContainerRef); - const isPagesSidebarHidden = useStore((state) => state.getPagesSidebarVisibility('canvas'), shallow); + // const editorMarginRight = useRightSidebarMargin(canvasContainerRef); + // const isPagesSidebarHidden = useStore((state) => state.getPagesSidebarVisibility('canvas'), shallow); const isSidebarOpen = useStore((state) => state.isSidebarOpen, shallow); const getPageId = useStore((state) => state.getCurrentPageId, shallow); + const isRightSidebarOpen = useStore((state) => state.isRightSidebarOpen, shallow); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned, shallow); + const currentPageId = useStore((state) => state.modules[moduleId].currentPageId); + const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + + const [isViewerSidebarPinned, setIsSidebarPinned] = useState( + localStorage.getItem('isPagesSidebarPinned') !== 'false' + ); + + const { globalSettings, pages, pageSettings, switchPage } = useStore( + (state) => ({ + globalSettings: state.globalSettings, + pages: state.modules.canvas.pages, + pageSettings: state.pageSettings, + switchPage: state.switchPage, + }), + shallow + ); + + const showHeader = !globalSettings?.hideHeader; + const { definition: { styles = {}, properties = {} } = {} } = pageSettings ?? {}; + const { position, disableMenu, showOnDesktop } = properties ?? {}; + const isPagesSidebarHidden = resolveReferences(disableMenu?.value); const hideSidebar = isModuleMode || isPagesSidebarHidden || appType === 'module'; @@ -79,9 +107,11 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => handleResize(); return () => window.removeEventListener('resize', handleResize); - }, [currentLayout, canvasMaxWidth, isViewerSidebarPinned, moduleId]); + }, [currentLayout, canvasMaxWidth, isViewerSidebarPinned, moduleId, isRightSidebarOpen]); - const styles = useMemo(() => { + useEffect(() => {}, [isViewerSidebarPinned]); + + const canvasContainerStyles = useMemo(() => { const canvasBgColor = currentMode === 'view' ? computeViewerBackgroundColor(isAppDarkMode, canvasBgColor) @@ -101,24 +131,37 @@ export const AppCanvas = ({ appId, isViewerSidebarPinned, isViewer = false }) => borderLeft: currentMode === 'edit' && editorMarginLeft + 'px solid', height: currentMode === 'edit' ? canvasContainerHeight : '100%', background: canvasBgColor, - marginLeft: - isViewerSidebarPinned && !hideSidebar && currentLayout !== 'mobile' && currentMode !== 'edit' - ? pageSidebarStyle === 'icon' - ? '65px' - : '210px' - : 'auto', + width: currentMode === 'edit' ? `calc(100% - 96px)` : '100%', + alignItems: 'unset', + justifyContent: 'unset', + borderRight: currentMode === 'edit' && isRightSidebarOpen && '299' + 'px solid', + padding: currentMode === 'edit' && '8px', + paddingBottom: currentMode === 'edit' && '2px', }; - }, [ - currentMode, - isAppDarkMode, - isModuleMode, - editorMarginLeft, - canvasContainerHeight, - isViewerSidebarPinned, - hideSidebar, - currentLayout, - pageSidebarStyle, - ]); + }, [currentMode, isAppDarkMode, isModuleMode, editorMarginLeft, canvasContainerHeight, isRightSidebarOpen]); + + const toggleSidebarPinned = useCallback(() => { + const newValue = !isViewerSidebarPinned; + setIsSidebarPinned(newValue); + localStorage.setItem('isPagesSidebarPinned', JSON.stringify(newValue)); + }, [isViewerSidebarPinned]); + + function getMinWidth() { + if (isModuleMode) return '100%'; + + const shouldAdjust = isSidebarOpen || (isRightSidebarOpen && currentMode === 'edit'); + + if (!shouldAdjust) return ''; + + let offset; + if (isViewerSidebarPinned) { + offset = position === 'side' ? '352px' : '126px'; + } else { + offset = position === 'side' ? '171px' : '126px'; + } + + return `calc(100vw - ${offset})`; + } return (
onMouseUp={handleCanvasContainerMouseUp} > -
+
diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx index c019e16f1a..4e9eb82455 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Input.jsx @@ -10,6 +10,7 @@ export const Input = ({ value, onChange, cyLabel, meta }) => { className="tj-input-element tj-text-xsm" value={value} placeholder="" + key={`${String(cyLabel)}-input`} id="labelId" onChange={(e) => { onChange(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx index 7811562e12..1127dbaf93 100644 --- a/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx +++ b/frontend/src/AppBuilder/CodeBuilder/Elements/Number.jsx @@ -1,13 +1,18 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; export const Number = ({ value, onChange, cyLabel }) => { const [number, setNumber] = useState(value ? value : 0); + useEffect(() => { + setNumber(value); + }, [value]); + return ( <>
{ setNumber(e.target.value); diff --git a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js index a249f52676..dac7203e6c 100644 --- a/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js +++ b/frontend/src/AppBuilder/CodeBuilder/TypeMapping.js @@ -1,3 +1,5 @@ +import { drop } from 'lodash'; + export const TypeMapping = { text: 'Text', string: 'Text', @@ -20,5 +22,6 @@ export const TypeMapping = { visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }; diff --git a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx index bd5d85ad98..2814fc188e 100644 --- a/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx +++ b/frontend/src/AppBuilder/CodeEditor/CodeHinter.jsx @@ -20,7 +20,15 @@ const CODE_EDITOR_TYPE = { tjdbHinter: TJDBCodeEditor, }; -const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, renderCopilot, ...restProps }) => { +const CodeHinter = ({ + type = 'basic', + initialValue, + componentName, + disabled, + renderCopilot, + setCodeEditorView, + ...restProps +}) => { const darkMode = localStorage.getItem('darkMode') === 'true'; const [isOpen, setIsOpen] = React.useState(false); @@ -71,6 +79,7 @@ const CodeHinter = ({ type = 'basic', initialValue, componentName, disabled, ren }} componentName={componentName} disabled={disabled} + setCodeEditorView={setCodeEditorView} {...restProps} /> ); diff --git a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx index 99d80a6050..4d4ff7513c 100644 --- a/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx +++ b/frontend/src/AppBuilder/CodeEditor/DynamicFxTypeRenderer.jsx @@ -17,6 +17,7 @@ import { Visibility } from '../CodeBuilder/Elements/Visibility'; import { NumberInput } from '../CodeBuilder/Elements/NumberInput'; import { Datepicker } from '../CodeBuilder/Elements/Datepicker'; import TableRowHeightInput from '../CodeBuilder/Elements/TableRowHeightInput'; +import DropdownMenu from '../CodeBuilder/Elements/DropdownMenu'; import { TimePicker } from '../CodeBuilder/Elements/TimePicker'; import { Query } from '../CodeBuilder/Elements/Query'; import { ColorSwatches } from '@/modules/Appbuilder/components'; @@ -41,6 +42,7 @@ const AllElements = { TableRowHeightInput, Datepicker, TimePicker, + DropdownMenu, Query, }; diff --git a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx index 98af1dc9e4..ce3c310f24 100644 --- a/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/MultiLineCodeEditor.jsx @@ -54,11 +54,15 @@ const MultiLineCodeEditor = (props) => { readOnly = false, editable = true, renderCopilot, + setCodeEditorView, } = props; const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); const wrapperRef = useRef(null); const getSuggestions = useStore((state) => state.getSuggestions, shallow); - const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow); + const getServerSideGlobalResolveSuggestions = useStore( + (state) => state.getServerSideGlobalResolveSuggestions, + shallow + ); const isInsideQueryPane = !!document.querySelector('.code-hinter-wrapper')?.closest('.query-details'); const isInsideQueryManager = useMemo( @@ -72,13 +76,48 @@ const MultiLineCodeEditor = (props) => { const currentValueRef = useRef(initialValue); - const handleChange = (val) => (currentValueRef.current = val); - const [editorView, setEditorView] = React.useState(null); const [isSearchPanelOpen, setIsSearchPanelOpen] = React.useState(false); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onChange, currentValueRef, 'multiline'); + // Add state for tracking autocomplete visibility + const [showSuggestions, setShowSuggestions] = React.useState(true); + const currentLineObserverRef = useRef(null); + const isObserverTriggeredRef = useRef(false); + + // Intersection observer to detect when current line goes out of view + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.intersectionRatio < 1) { + setShowSuggestions(false); + isObserverTriggeredRef.current = true; + // Close autocomplete dropdown by dispatching a selection change + if (editorView) { + editorView.dispatch({ + selection: editorView.state.selection, + }); + } + } else { + setShowSuggestions(true); + isObserverTriggeredRef.current = false; + } + }, + { root: null, threshold: [1] } + ); + + currentLineObserverRef.current = observer; + + return () => { + if (currentLineObserverRef.current) { + currentLineObserverRef.current.disconnect(); + } + }; + }, [editorView]); + + const handleChange = (val) => (currentValueRef.current = val); + const handleOnBlur = () => { if (!delayOnChange) return onChange(currentValueRef.current); setTimeout(() => { @@ -116,7 +155,7 @@ const MultiLineCodeEditor = (props) => { const hints = getSuggestions(); - const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager); + const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); const allHints = { ...hints, @@ -276,6 +315,21 @@ const MultiLineCodeEditor = (props) => { return initialValue; }, [initialValue, replaceIdsWithName]); + function updateCurrentLineObserver(editorView) { + if (!editorView || !editorView?.view?.dom) return; + const cursorPos = editorView.state.selection.main.head; + const line = editorView.state.doc.lineAt(cursorPos); + const lineNumber = line.number; + const cmLines = editorView.view.dom.querySelectorAll('.cm-line'); + const currentLineDiv = cmLines[lineNumber - 1] || null; + + // Update intersection observer to watch the current line + if (currentLineObserverRef.current && currentLineDiv && !isObserverTriggeredRef.current) { + currentLineObserverRef.current.disconnect(); + currentLineObserverRef.current.observe(currentLineDiv); + } + } + return (
{ indentWithTab={false} readOnly={readOnly} editable={editable} //for transformations in query manager - onCreateEditor={(view) => setEditorView(view)} - onUpdate={(view) => setIsSearchPanelOpen(searchPanelOpen(view.state))} + onCreateEditor={(view) => { + setEditorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } + }} + onUpdate={(view) => { + setIsSearchPanelOpen(searchPanelOpen(view.state)); + updateCurrentLineObserver(view); + }} />
{showPreview && ( diff --git a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx index 5c422b1eb3..f2b7be403a 100644 --- a/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx +++ b/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx @@ -98,7 +98,7 @@ export const PreviewBox = ({ const [largeDataset, setLargeDataset] = useState(false); const globals = useStore((state) => state.getAllExposedValues(moduleId).constants || {}, shallow); const secrets = useStore((state) => state.getSecrets(), shallow); - const globalServerConstantsRegex = /^\{\{.*globals\.server.*\}\}$/; + const globalServerConstantsRegex = /\{\{.*globals\.server.*\}\}/; const getPreviewContent = (content, type) => { if (content === undefined || content === null) return currentValue; @@ -251,7 +251,10 @@ const RenderResolvedValue = ({ isServerConstant = false, isLargeDataset, }) => { - const isServerSideGlobalEnabled = useStore((state) => !!state?.license?.featureAccess?.serverSideGlobal, shallow); + const isServerSideGlobalResolveEnabled = useStore( + (state) => !!state?.license?.featureAccess?.serverSideGlobalResolve, + shallow + ); const computeCoersionPreview = (resolvedValue, coersionData) => { if (coersionData?.typeBeforeCoercion === coersionData?.typeAfterCoercion) return resolvedValue; @@ -276,7 +279,7 @@ const RenderResolvedValue = ({ : previewType; const previewContent = isServerConstant - ? isServerSideGlobalEnabled + ? isServerSideGlobalResolveEnabled ? 'Server variables would be resolved at runtime' : 'Server variables are only available in paid plans' : isSecretConstant @@ -486,7 +489,14 @@ const PreviewContainer = ({ }; const PreviewCodeBlock = ({ code, isExpectValue = false, isLargeDataset }) => { - let preview = code && code.trim ? code?.trim() : `${code}`; + let preview; + if (typeof code === 'string') { + preview = code.trim(); + } else if (typeof code === 'symbol') { + preview = code.toString(); + } else { + preview = String(code); + } const shouldTrim = preview.length > 35; let showJSONTree = false; diff --git a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx index 9c85e0bf43..c3d0bb798c 100644 --- a/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx +++ b/frontend/src/AppBuilder/CodeEditor/SingleLineCodeEditor.jsx @@ -28,10 +28,12 @@ import CodeHinter from './CodeHinter'; import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils'; import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; +import { getCssVarValue } from '@/Editor/Components/utils'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext'; import { createReferencesLookup } from '@/_stores/utils'; import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks'; +import Icon from '@/_ui/Icon/solidIcons/index'; const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...restProps }) => { const { moduleId } = useModuleContext(); @@ -79,7 +81,6 @@ const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...r const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); let newInitialValue = initialValue; - if (typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries'))) { newInitialValue = replaceIdsWithName(initialValue); } @@ -209,6 +210,7 @@ const EditorInput = ({ onInputChange, wrapperRef, showSuggestions, + setCodeEditorView = null, // Function to set the CodeMirror view }) => { const codeHinterContext = useContext(CodeHinterContext); const { suggestionList: paramHints } = createReferencesLookup(codeHinterContext, true); @@ -216,7 +218,10 @@ const EditorInput = ({ const getSuggestions = useStore((state) => state.getSuggestions, shallow); const [codeMirrorView, setCodeMirrorView] = useState(undefined); - const getServerSideGlobalSuggestions = useStore((state) => state.getServerSideGlobalSuggestions, shallow); + const getServerSideGlobalResolveSuggestions = useStore( + (state) => state.getServerSideGlobalResolveSuggestions, + shallow + ); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline'); @@ -226,7 +231,7 @@ const EditorInput = ({ ); function autoCompleteExtensionConfig(context) { const hintsWithoutParamHints = getSuggestions(); - const serverHints = getServerSideGlobalSuggestions(isInsideQueryManager); + const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); let word = context.matchBefore(/\w*/); @@ -274,7 +279,10 @@ const EditorInput = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps - const overRideFunction = React.useCallback((context) => autoCompleteExtensionConfig(context), [isInsideQueryManager, paramHints]); + const overRideFunction = React.useCallback( + (context) => autoCompleteExtensionConfig(context), + [isInsideQueryManager, paramHints] + ); const autoCompleteConfig = autocompletion({ override: [overRideFunction], @@ -443,6 +451,9 @@ const EditorInput = ({ { setCodeMirrorView(view); + if (setCodeEditorView) { + setCodeEditorView(view); + } }} value={currentValue} placeholder={placeholder} @@ -451,11 +462,11 @@ const EditorInput = ({ extensions={ showSuggestions ? [ - javascript({ jsx: lang === 'jsx' }), - autoCompleteConfig, - keymap.of([...customKeyMaps]), - customTabKeymap, - ] + javascript({ jsx: lang === 'jsx' }), + autoCompleteConfig, + keymap.of([...customKeyMaps]), + customTabKeymap, + ] : [javascript({ jsx: lang === 'jsx' })] } onChange={(val) => { @@ -487,9 +498,9 @@ const EditorInput = ({ }} />
- - -
+ + + ); }; @@ -514,24 +525,49 @@ const DynamicEditorBridge = (props) => { const [forceCodeBox, setForceCodeBox] = React.useState(fxActive); const codeShow = paramType === 'code' || forceCodeBox; - const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format']; - const { isFxNotRequired } = fieldMeta; + const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format', 'Slider type']; + const { isFxNotRequired, newLine = false, section = '' } = fieldMeta; + const isDeprecated = section === 'deprecated'; const { t } = useTranslation(); - const [_, error, value] = type === 'fxEditor' ? resolveReferences(initialValue) : []; + const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); + let newInitialValue = initialValue, + shouldResolve = true; + + // This is to handle the case when the initial value is a string and contains components or queries + // and we need to replace the ids with names + // but we don't want to resolve the references as it needs to be displayed as it is + if (paramName === 'generateFormFrom') { + if ( + typeof initialValue === 'string' && + (initialValue?.includes('components') || initialValue?.includes('queries')) + ) { + newInitialValue = replaceIdsWithName(initialValue); + shouldResolve = false; + } + } + const [_, error, value] = + type === 'fxEditor' ? (shouldResolve ? resolveReferences(newInitialValue) : [false, '', newInitialValue]) : []; let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; useEffect(() => { setForceCodeBox(fxActive); }, [component, fxActive]); + let modifiedValue = initialValue; + if (paramType === 'colorSwatches' && typeof initialValue === 'string' && initialValue?.includes('var(')) { + modifiedValue = getCssVarValue(document.documentElement, initialValue); + } + const renderFx = () => { if (paramType === 'query' || !(paramLabel !== 'Type' && isFxNotRequired === undefined)) { return null; } + return (
{ if (codeShow) { setForceCodeBox(false); onFxPress(false); + if (paramType === 'colorSwatches') { + onChange(modifiedValue); + } } else { setForceCodeBox(true); onFxPress(true); @@ -551,48 +590,69 @@ const DynamicEditorBridge = (props) => { }; const fxClass = isEventManagerParam ? 'justify-content-start' : 'justify-content-end'; - return ( -
-
+ + const renderedLabel = () => { + return ( + <> {paramLabel !== ' ' && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
+ {isDeprecated && ( + + + + )}
)} + + ); + }; + + const renderDynamicFx = () => { + if (codeShow) return null; + return ( + { + setForceCodeBox(true); + onFxPress(true); + }} + meta={fieldMeta} + cyLabel={cyLabel} + styleDefinition={styleDefinition} + component={component} + onVisibilityChange={onVisibilityChange} + /> + ); + }; + + return ( +
+
+ {renderedLabel()}
{renderFx()}
- {!codeShow && ( - { - setForceCodeBox(true); - onFxPress(true); - }} - meta={fieldMeta} - cyLabel={cyLabel} - styleDefinition={styleDefinition} - component={component} - onVisibilityChange={onVisibilityChange} - /> - )} + {!newLine && renderDynamicFx()}
+ {newLine && renderDynamicFx()} {codeShow && (
- +
diff --git a/frontend/src/AppBuilder/CodeEditor/styles.scss b/frontend/src/AppBuilder/CodeEditor/styles.scss index d0a328bae0..9b1e6c7023 100644 --- a/frontend/src/AppBuilder/CodeEditor/styles.scss +++ b/frontend/src/AppBuilder/CodeEditor/styles.scss @@ -660,6 +660,13 @@ } } + +.code-editor-component { + .cm-editor { + min-height: 0 !important; + } +} + .cm-searchMatch.cm-searchMatch-selected { background-color: #F28F2D !important; } @@ -673,4 +680,4 @@ .cm-theme{ height: 100% ; } -} \ No newline at end of file +} diff --git a/frontend/src/AppBuilder/CodeEditor/utils.js b/frontend/src/AppBuilder/CodeEditor/utils.js index 1b5fe0aafb..227a4a74e5 100644 --- a/frontend/src/AppBuilder/CodeEditor/utils.js +++ b/frontend/src/AppBuilder/CodeEditor/utils.js @@ -367,6 +367,7 @@ export const FxParamTypeMapping = Object.freeze({ visibility: 'Visibility', numberInput: 'NumberInput', tableRowHeightInput: 'TableRowHeightInput', + dropdownMenu: 'DropdownMenu', query: 'Query', }); diff --git a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx index 64208e5d27..66e62fcd8b 100644 --- a/frontend/src/AppBuilder/Header/CreateVersionModal.jsx +++ b/frontend/src/AppBuilder/Header/CreateVersionModal.jsx @@ -16,15 +16,11 @@ const CreateVersionModal = ({ canCommit, orgGit, fetchingOrgGit, - handleCommitOnVersionCreation = () => {}, + handleCommitOnVersionCreation = () => { }, }) => { const { moduleId } = useModuleContext(); const [isCreatingVersion, setIsCreatingVersion] = useState(false); const [versionName, setVersionName] = useState(''); - const gitSyncEnabled = - orgGit?.org_git?.git_https?.is_enabled || - orgGit?.org_git?.git_ssh?.is_enabled || - orgGit?.org_git?.git_lab?.is_enabled; const { createNewVersionAction, @@ -33,6 +29,7 @@ const CreateVersionModal = ({ appId, setCurrentVersionId, selectedVersion, + currentMode, } = useStore( (state) => ({ createNewVersionAction: state.createNewVersionAction, @@ -45,6 +42,7 @@ const CreateVersionModal = ({ currentVersionId: state.currentVersionId, setCurrentVersionId: state.setCurrentVersionId, selectedVersion: state.selectedVersion, + currentMode: state.currentMode, }), shallow ); @@ -94,7 +92,7 @@ const CreateVersionModal = ({ setIsCreatingVersion(false); setShowCreateAppVersion(false); appVersionService - .getAppVersionData(appId, newVersion.id) + .getAppVersionData(appId, newVersion.id, currentMode) .then((data) => { setCurrentVersionId(newVersion.id); handleCommitOnVersionCreation(data); @@ -104,8 +102,8 @@ const CreateVersionModal = ({ }); }, (error) => { - if (error?.data?.code === '23505') { - toast.error('Version name already exists.'); + if (error?.data?.code === "23505") { + toast.error("Version name already exists."); } else { toast.error(error?.error); } @@ -174,7 +172,7 @@ const CreateVersionModal = ({
- {gitSyncEnabled && ( + {orgGit?.org_git?.is_enabled && (
{ const { moduleId } = useModuleContext(); @@ -117,77 +119,64 @@ const CanvasSettings = ({ darkMode }) => {
-
+
{t('leftSidebar.Settings.backgroundColorOfCanvas', 'Canvas bavkground')}
- {showPicker && ( -
-
setShowPicker(false)} /> - setShowPicker(true)} - color={canvasBackgroundColor} - onChangeComplete={(color) => { +
+ { + if (typeof canvasBackgroundColor === 'string' && canvasBackgroundColor?.includes('var(')) { + const value = getCssVarValue(document.documentElement, canvasBackgroundColor); const options = { - canvasBackgroundColor: [color.hex, color.rgb], - backgroundFxQuery: '', + canvasBackgroundColor: value, + backgroundFxQuery: value, }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: [color.hex, color.rgb] }); - }} - /> -
- )} + await Promise.resolve(globalSettingsChanged(options)); + await Promise.resolve(resolveOthers('canvas', true, { canvasBackgroundColor: value })); + } + setForceCodeBox(!forceCodeBox); + }} + /> +
{forceCodeBox && ( -
setShowPicker(true)} style={outerStyles}> -
-
- {canvasBackgroundColor} -
-
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> )}
{!forceCodeBox && ( - { - const options = { - canvasBackgroundColor: resolveReferences(color), - backgroundFxQuery: color, - }; - globalSettingsChanged(options); - resolveOthers('canvas', true, { canvasBackgroundColor: color }); - }} - /> +
+ { + const options = { + canvasBackgroundColor: resolveReferences(color), + backgroundFxQuery: color, + }; + globalSettingsChanged(options); + resolveOthers('canvas', true, { canvasBackgroundColor: color }); + }} + /> +
)} -
- { - setForceCodeBox(!forceCodeBox); - }} - /> -
diff --git a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx index d85917c797..9f8379f85c 100644 --- a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/index.jsx @@ -10,12 +10,13 @@ import AppModeToggle from './AppModeToggle'; import { ThemeSelect } from '@/modules/Appbuilder/components'; import MaintenanceMode from './MaintenanceMode'; import HideHeaderToggle from './HideHeaderToggle'; +import { ModuleProvider } from '@/AppBuilder/_contexts/ModuleContext'; const GlobalSettings = ({ darkMode }) => { const shouldFreeze = useStore((state) => state.getShouldFreeze()); return ( - <> +
@@ -44,7 +45,7 @@ const GlobalSettings = ({ darkMode }) => {
- + ); }; diff --git a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx index e2b5947206..bcd90bee33 100644 --- a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx @@ -5,13 +5,14 @@ import cx from 'classnames'; import { shallow } from 'zustand/shallow'; import { DarkModeToggle } from '@/_components'; import Popover from '@/_ui/Popover'; -import { PageMenu } from './PageMenu'; +// import { PageMenu } from './PageMenu'; import LeftSidebarInspector from './LeftSidebarInspector/LeftSidebarInspector'; import GlobalSettings from './GlobalSettings'; import '../../_styles/left-sidebar.scss'; import Debugger from './Debugger/Debugger'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; +import { PageMenu } from '../RightSideBar/PageSettingsTab/PageMenu'; // TODO: remove passing refs to LeftSidebarItem and use state // TODO: need to add datasources to the sidebar. @@ -58,7 +59,6 @@ export const BaseLeftSidebar = ({ const sideBarBtnRefs = useRef({}); const handleSelectedSidebarItem = (item) => { - pinned && localStorage.setItem('selectedSidebarItem', item); if (item === 'debugger') resetUnreadErrorCount(); setSelectedSidebarItem(item); if (item === selectedSidebarItem && !pinned) { @@ -211,15 +211,6 @@ export const BaseLeftSidebar = ({ tip: 'Build with AI', ref: setSideBarBtnRefs('tooljetai'), })} - handleSelectedSidebarItem('page')} - darkMode={darkMode} - icon="page" - className={`left-sidebar-item left-sidebar-layout left-sidebar-page-selector`} - tip="Pages" - ref={setSideBarBtnRefs('page')} - /> {renderCommonItems()} { marginTop: level === 1 ? 4 : 0, marginBottom: level === 1 ? 4 : 0, // borderLeft: level > 1 ? '1px solid var(--slate6, #D7DBDF)' : 'none', + cursor: level === 1 ? 'pointer' : 'default', }} + {...(level === 1 && { onClick: () => onExpand(props) })} > {/* {!['queries', 'globals', 'variables'].includes(type) && ( */}
diff --git a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx b/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx deleted file mode 100644 index 3f8b3c09d3..0000000000 --- a/frontend/src/AppBuilder/LeftSidebar/PageMenu/PageMenuItem.jsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { memo, useRef, useState, useCallback } from 'react'; -import cx from 'classnames'; -// import { RenameInput } from './RenameInput'; -// import { PagehandlerMenu } from './PagehandlerMenu'; -// import { EditModal } from './EditModal'; -// import { SettingsModal } from './SettingsModal'; -import { useAppVersionStore } from '@/_stores/appVersionStore'; -import SolidIcon from '@/_ui/Icon/SolidIcons'; -import EyeDisable from '@/_ui/Icon/solidIcons/EyeDisable'; -import FileRemove from '@/_ui/Icon/solidIcons/FIleRemove'; -import Home from '@/_ui/Icon/solidIcons/Home'; -import useStore from '@/AppBuilder/_stores/store'; -import _ from 'lodash'; -import { toast } from 'react-hot-toast'; -import { RenameInput } from './RenameInput'; -import IconSelector from './IconSelector'; -import { withRouter } from '@/_hoc/withRouter'; -import OverflowTooltip from '@/_components/OverflowTooltip'; -import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; -import { shallow } from 'zustand/shallow'; -import { ToolTip } from '@/_components/ToolTip'; - -export const PageMenuItem = withRouter( - memo(({ darkMode, page, navigate }) => { - const { moduleId } = useModuleContext(); - const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); - const isHomePage = page.id === homePageId; - const currentPageId = useStore((state) => state.modules[moduleId].currentPageId); - const isSelected = page.id === currentPageId; - const isHidden = page?.hidden ?? false; - const isDisabled = page?.disabled ?? false; - const [isHovered, setIsHovered] = useState(false); - const shouldFreeze = useStore((state) => state.getShouldFreeze()); - const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); - const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; - const showEditingPopover = useStore((state) => state.showEditingPopover); - const restricted = page?.permissions && page?.permissions?.length > 0; - const { - definition: { styles, properties }, - } = useStore((state) => state.pageSettings); - const setCurrentPageHandle = useStore((state) => state.setCurrentPageHandle); - // only update when the page is being edited - const editingPage = useStore( - (state) => state.editingPage, - (prev, next) => { - if (next?.id === page?.id) return false; - if (prev?.id === page?.id) return false; - return true; - } - ); - const editingPageName = useStore((state) => state.showEditPageNameInput); - const popoverRef = useRef(null); - - const openPageEditPopover = useStore((state) => state.openPageEditPopover); - const toggleEditPageNameInput = useStore((state) => state.toggleEditPageNameInput); - - const isEditingPage = editingPage?.id === page?.id; - const icon = () => { - const iconName = isHomePage && !page.icon ? 'IconHome2' : page.icon; - if (!isDisabled && !isHidden) { - return ; - } - if (isDisabled || (isDisabled && isHidden)) { - return ( - - ); - } - if (isHidden && !isDisabled) { - return ; - } - }; - - const computeStyles = useCallback(() => { - const baseStyles = { - pill: { - borderRadius: `${styles.pillRadius.value}px`, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - - switch (true) { - case isSelected: { - return { - ...baseStyles, - text: { - color: !styles.selectedTextColor.isDefault && styles.selectedTextColor.value, - }, - icon: { - stroke: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - color: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - fill: !styles.selectedIconColor.isDefault && styles.selectedIconColor.value, - }, - pill: { - background: !styles.pillSelectedBackgroundColor.isDefault && styles.pillSelectedBackgroundColor.value, - ...(page.id === editingPage?.id && { - backgroundColor: 'var(--slate1)', - }), - ...baseStyles.pill, - }, - }; - } - case isHovered: { - return { - ...baseStyles, - pill: { - background: !styles.pillHoverBackgroundColor.isDefault && styles.pillHoverBackgroundColor.value, - ...baseStyles.pill, - }, - }; - } - default: { - return { - text: { - color: !styles.textColor.isDefault && styles.textColor.value, - }, - icon: { - color: !styles.iconColor.isDefault && styles.iconColor.value, - fill: !styles.iconColor.isDefault && styles.iconColor.value, - }, - }; - } - } - }, [styles, isSelected, isHovered, page.id, editingPage?.id]); - - const computedStyles = computeStyles(); - - const labelStyle = { - icon: { - hidden: properties.style === 'text', - }, - label: { - hidden: properties.style === 'icon', - }, - }; - - const switchPage = useStore((state) => state.switchPage); - - const handlePageSwitch = useCallback(() => { - if (currentPageId === page.id) { - return; - } - switchPage(page.id, page.handle, [], moduleId); - setCurrentPageHandle(page.handle, moduleId); - }, [currentPageId, page.id, page.handle, switchPage, setCurrentPageHandle, moduleId]); - - const handlePageMenuSettings = useCallback( - (event) => { - event.stopPropagation(); - openPageEditPopover(page, popoverRef); - }, - [popoverRef.current, page] - ); - - function getTooltip() { - const permission = page?.permissions?.length ? page?.permissions[0] : null; - if (!permission) return ''; - const users = permission.users || []; - const isSingle = permission.type === 'SINGLE'; - const isGroup = permission.type === 'GROUP'; - - if (users.length === 0) return null; - - if (isSingle) { - if (users.length === 1) { - const email = users[0].user.email; - return `Access restricted to ${email}`; - } else { - return `Access restricted to ${users.length} users`; - } - } - - if (isGroup) { - if (users.length === 1) { - const groupName = users[0].permissionGroup?.name ?? 'Group'; - return `Access restricted to ${groupName} group`; - } else { - return `Access restricted to ${users.length} groups`; - } - } - - return ''; - } - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - style={{ - width: '100%', - }} - > - <> -
- {editingPageName && editingPage?.id === page?.id ? ( - <> - {' '} -
{icon()}
- { - toggleEditPageNameInput(false); - }} - /> - - ) : ( - <> - {' '} -
- {icon()} - - {page.name} - - - {isHomePage && 'Home'} - {isDisabled && 'Disabled'} - {isHidden && !isDisabled && 'Hidden'} - -
-
- {licenseValid && restricted && ( - -
- -
-
- )} -
-
- {!shouldFreeze && ( - - )} -
- - )} -
- -
- ); - }) -); - -export const AddingPageHandler = ({ darkMode }) => { - const toggleShowAddNewPageInput = useStore((state) => state.toggleShowAddNewPageInput); - const addNewPage = useStore((state) => state.addNewPage); - const isPageGroup = useStore((state) => state.isPageGroup); - const handleAddingNewPage = (pageName) => { - if (pageName.trim().length === 0) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name should have at least 1 character`, { - icon: '⚠️', - }); - } else if (pageName.trim().length > 32) { - toast(`${isPageGroup ? 'Page group' : 'Page'} name cannot exceed 32 characters`, { - icon: '⚠️', - }); - } else { - addNewPage(pageName, _.kebabCase(pageName.toLowerCase()), isPageGroup); - } - toggleShowAddNewPageInput(false); - }; - - return ( -
-
- { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - const name = event.target.value; - handleAddingNewPage(name); - event.stopPropagation(); - } - }} - /> -
-
- ); -}; diff --git a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx index a2cf74da10..d9a670f200 100644 --- a/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx +++ b/frontend/src/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/AggregateUI/index.jsx @@ -277,7 +277,11 @@ export const AggregateFilter = ({ darkMode, operation = '' }) => { }; const aggFxOptions = [ - { label: 'Sum', value: 'sum', description: 'Sum of all values in this column' }, + { + label: 'Sum', + value: 'sum', + description: 'Sum of all values in this column', + }, { label: 'Count', value: 'count', @@ -402,7 +406,11 @@ export const AggregateFilter = ({ darkMode, operation = '' }) => { />
handleDeleteAggregate(aggregateKey)} > diff --git a/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx b/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx index 7328b7dfc6..91c14257ef 100644 --- a/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx +++ b/frontend/src/AppBuilder/QueryPanel/QueryCard.jsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { Tooltip } from 'react-tooltip'; +import { ToolTip } from '@/_components/ToolTip'; import { updateQuerySuggestions } from '@/_helpers/appUtils'; // import { Confirm } from '../Viewer/Confirm'; import { toast } from 'react-hot-toast'; import { shallow } from 'zustand/shallow'; -import Copy from '@/_ui/Icon/solidIcons/Copy'; import DataSourceIcon from '../QueryManager/Components/DataSourceIcon'; import { isQueryRunnable, decodeEntities } from '@/_helpers/utils'; import { canDeleteDataSource, canReadDataSource, canUpdateDataSource } from '@/_helpers'; @@ -12,13 +12,10 @@ import useStore from '@/AppBuilder/_stores/store'; //TODO: Remove this import { Confirm } from '@/Editor/Viewer/Confirm'; // TODO: enable delete query confirmation popup -import { debounce } from 'lodash'; -import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => { - const { moduleId } = useModuleContext(); - const appId = useStore((state) => state.appStore.modules[moduleId].app.appId); - const isQuerySelected = useStore((state) => state.queryPanel.isQuerySelected(dataQuery.id), shallow); const setSelectedQuery = useStore((state) => state.queryPanel.setSelectedQuery); const checkExistingQueryName = useStore((state) => state.dataQuery.checkExistingQueryName); @@ -26,9 +23,16 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => { const isDeletingQueryInProcess = useStore((state) => state.dataQuery.isDeletingQueryInProcess); const renameQuery = useStore((state) => state.dataQuery.renameQuery); const deleteDataQueries = useStore((state) => state.dataQuery.deleteDataQueries); - const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery); const setPreviewData = useStore((state) => state.queryPanel.setPreviewData); - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const shouldFreeze = useStore((state) => state.getShouldFreeze()); + + const renamingQueryId = useStore((state) => state.queryPanel.renamingQueryId); + const deletingQueryId = useStore((state) => state.queryPanel.deletingQueryId); + const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery); + const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery); + const isRenaming = renamingQueryId === dataQuery.id; + const isDeleting = deletingQueryId === dataQuery.id; + const hasPermissions = selectedDataSourceScope === 'global' ? canUpdateDataSource(dataQuery?.data_source_id) || @@ -36,57 +40,77 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => { canDeleteDataSource() : true; - const shouldFreeze = useStore((state) => state.getShouldFreeze()); - - const [renamingQuery, setRenamingQuery] = useState(false); - - const deleteDataQuery = (e) => { - e.stopPropagation(); - setShowDeleteConfirmation(true); - }; + const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const isRestricted = dataQuery.permissions && dataQuery.permissions.length !== 0; const updateQueryName = (dataQuery, newName) => { const { name } = dataQuery; if (name === newName) { - return setRenamingQuery(false); + return setRenamingQuery(null); } const isNewQueryNameAlreadyExists = checkExistingQueryName(newName); if (newName && !isNewQueryNameAlreadyExists) { renameQuery(dataQuery?.id, newName); - setRenamingQuery(false); + setRenamingQuery(null); updateQuerySuggestions(name, newName); } else { if (isNewQueryNameAlreadyExists) { toast.error('Query name already exists'); } - setRenamingQuery(false); + setRenamingQuery(null); } }; const executeDataQueryDeletion = () => { - setShowDeleteConfirmation(false); + deleteDataQuery(null); deleteDataQueries(dataQuery?.id); setPreviewData(null); }; - // To prevent user clicking from continuous clicks - const debouncedDuplicateQuery = useCallback( - debounce((queryId, appId) => { - duplicateQuery(queryId, appId); - setPreviewData(null); - }, 500), - [duplicateQuery] - ); + const getTooltip = () => { + const permission = dataQuery.permissions?.[0]; + if (!permission) return null; + + const users = permission.groups || permission.users || []; + if (users.length === 0) return null; + + const isSingle = permission.type === 'SINGLE'; + const isGroup = permission.type === 'GROUP'; + + if (isSingle) { + return users.length === 1 + ? `Access restricted to ${users[0].user.email}` + : `Access restricted to ${users.length} users`; + } + + if (isGroup) { + return users.length === 1 + ? `Access restricted to ${users[0].permission_group?.name || users[0].permissionGroup?.name} group` + : `Access restricted to ${users.length} user groups`; + } + + return null; + }; return ( <>
{ + onClick={(e) => { if (isQuerySelected) return; - setSelectedQuery(dataQuery?.id); - setPreviewData(null); + const menuBtn = document.getElementById(`query-handler-menu-${dataQuery?.id}`); + if (menuBtn.contains(e.target)) { + e.stopPropagation(); + } else { + toggleQueryHandlerMenu(false); + } + setTimeout(() => { + setSelectedQuery(dataQuery?.id); + setPreviewData(null); + }, 0); }} role="button" > @@ -94,7 +118,7 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
- {renamingQuery ? ( + {isRenaming ? ( { data-tooltip-dynamic="true" > {decodeEntities(dataQuery.name)} - {' '} + + +
+ {licenseValid && isRestricted && } +
+
{' '} {!isQueryRunnable(dataQuery) && Draft} {localDs && ( <> @@ -143,80 +172,24 @@ export const QueryCard = ({ dataQuery, darkMode = false, localDs }) => {
)}
- {!shouldFreeze && isQuerySelected && ( -
-
setRenamingQuery(true)} - > - - - - - -
-
debouncedDuplicateQuery(dataQuery?.id, appId)} - > - - - -
-
- {isDeletingQueryInProcess ? ( -
-
-
- ) : ( - - - - - - - - )} -
- -
- )} +
+ toggleQueryHandlerMenu(true, `query-handler-menu-${dataQuery?.id}`)} + size="small" + variant="outline" + className="" + id={`query-handler-menu-${dataQuery?.id}`} + /> +
setShowDeleteConfirmation(false)} + onCancel={() => deleteDataQuery(null)} darkMode={darkMode} /> diff --git a/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx b/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx new file mode 100644 index 0000000000..a9e3030b51 --- /dev/null +++ b/frontend/src/AppBuilder/QueryPanel/QueryCardMenu.jsx @@ -0,0 +1,174 @@ +import React, { useCallback } from 'react'; +import { Overlay, Popover } from 'react-bootstrap'; +import useStore from '@/AppBuilder/_stores/store'; +import classNames from 'classnames'; +import Edit from '@/_ui/Icon/bulkIcons/Edit'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import Copy from '@/_ui/Icon/solidIcons/Copy'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { shallow } from 'zustand/shallow'; +import { ToolTip } from '@/_components/ToolTip'; +import { debounce } from 'lodash'; +import usePopoverObserver from '@/AppBuilder/_hooks/usePopoverObserver'; +import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; + +const QueryCardMenu = ({ darkMode }) => { + const { moduleId } = useModuleContext(); + const appId = useStore((state) => state.appStore.modules[moduleId].app.appId); + const selectedQuery = useStore((state) => state.queryPanel.selectedQuery); + const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const targetBtnForMenu = useStore((state) => state.queryPanel.targetBtnForMenu); + const targetElement = document.getElementById(targetBtnForMenu); + const showQueryHandlerMenu = useStore((state) => state.queryPanel.showQueryHandlerMenu); + const toggleQueryHandlerMenu = useStore((state) => state.queryPanel.toggleQueryHandlerMenu); + const duplicateQuery = useStore((state) => state.dataQuery.duplicateQuery); + const setPreviewData = useStore((state) => state.queryPanel.setPreviewData); + const setRenamingQuery = useStore((state) => state.queryPanel.setRenamingQuery); + const deleteDataQuery = useStore((state) => state.queryPanel.deleteDataQuery); + + const QUERY_MENU_OPTIONS = [ + { + label: 'Rename', + value: 'rename', + icon: , + showTooltip: false, + }, + { + label: 'Duplicate', + value: 'duplicate', + icon: , + showTooltip: false, + }, + { + label: 'Query permission', + value: 'permission', + icon: ( + permission-icon + ), + trailingIcon: , + }, + { + label: 'Delete', + value: 'delete', + icon: , + showTooltip: false, + }, + ]; + + // To prevent user clicking from continuous clicks + const debouncedDuplicateQuery = useCallback( + debounce((queryId, appId) => { + duplicateQuery(queryId, appId); + setPreviewData(null); + }, 500), + [duplicateQuery] + ); + + const handleQueryMenuActions = (value) => { + if (value === 'rename') { + setRenamingQuery(selectedQuery?.id); + } + if (value === 'duplicate') { + debouncedDuplicateQuery(selectedQuery?.id, appId); + } + if (value === 'permission') { + if (!licenseValid) return; + toggleQueryPermissionModal(true); + } + if (value === 'delete') { + deleteDataQuery(selectedQuery?.id); + } + toggleQueryHandlerMenu(false); + }; + + usePopoverObserver( + document.getElementsByClassName('query-list')[0], + targetElement, + document.getElementById('query-list-menu'), + showQueryHandlerMenu, + () => (document.getElementById('query-list-menu').style.display = 'block'), + () => (document.getElementById('query-list-menu').style.display = 'none') + ); + + return ( + toggleQueryHandlerMenu(false)} + popperConfig={{ + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: ['top-start'], + flipVariations: true, + allowedAutoPlacements: ['top', 'bottom'], + boundary: 'viewport', + }, + }, + { + name: 'offset', + options: { + offset: [0, 3], + }, + }, + ], + }} + > + {(props) => ( + + + {QUERY_MENU_OPTIONS.map((option) => { + const optionBody = ( +
{ + e.stopPropagation(); + handleQueryMenuActions(option.value); + }} + > +
{option.icon}
+
+ {option?.label} +
+ {option.value === 'permission' && !licenseValid && option.trailingIcon && option.trailingIcon} +
+ ); + + return option.value === 'permission' ? ( + + {optionBody} + + ) : ( + optionBody + ); + })} +
+
+ )} +
+ ); +}; + +export default QueryCardMenu; diff --git a/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx b/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx index 9ac052ae51..97b4daa68f 100644 --- a/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx +++ b/frontend/src/AppBuilder/QueryPanel/QueryDataPane.jsx @@ -16,6 +16,10 @@ import DataSourceSelect from '../QueryManager/Components/DataSourceSelect'; import { OverlayTrigger, Popover } from 'react-bootstrap'; import FolderEmpty from '@/_ui/Icon/solidIcons/FolderEmpty'; import useStore from '@/AppBuilder/_stores/store'; +import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal'; +import { shallow } from 'zustand/shallow'; +import { appPermissionService } from '@/_services'; +import QueryCardMenu from './QueryCardMenu'; export const QueryDataPane = ({ darkMode }) => { const { t } = useTranslation(); @@ -34,6 +38,12 @@ export const QueryDataPane = ({ darkMode }) => { function isDataSourceLocal(dataQuery) { return dataSources.some((dataSource) => dataSource.id === dataQuery.data_source_id); } + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const selectedQuery = useStore((state) => state.queryPanel.selectedQuery); + const showQueryPermissionModal = useStore((state) => state.queryPanel.showQueryPermissionModal); + const toggleQueryPermissionModal = useStore((state) => state.queryPanel.toggleQueryPermissionModal); + const setQueries = useStore((state) => state.dataQuery.setQueries); useEffect(() => { setQueryPanelSearchTerm(searchTermForFilters); @@ -171,6 +181,33 @@ export const QueryDataPane = ({ darkMode }) => { {filteredQueries.map((query) => ( ))} + + {licenseValid && ( + appPermissionService.getQueryPermission(appId, id)} + createPermission={(id, appId, body) => appPermissionService.createQueryPermission(appId, id, body)} + updatePermission={(id, appId, body) => appPermissionService.updateQueryPermission(appId, id, body)} + deletePermission={(id, appId) => appPermissionService.deleteQueryPermission(appId, id)} + onSuccess={(data) => { + const updatedDataQueries = dataQueries.map((query) => { + if (query.id === selectedQuery.id) { + return { + ...query, + permissions: data.length === 0 || data.length === undefined ? [] : [data[0]], + }; + } + return query; + }); + setQueries(updatedDataQueries); + }} + /> + )}
{ const selectedComponentId = useStore((state) => state.selectedComponents?.[0], shallow); + const activeTab = useStore((state) => state.activeRightSideBarTab, shallow); + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const setActiveRightSideBarTab = useStore((state) => state.setActiveRightSideBarTab); - if (!selectedComponentId) { - return setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.COMPONENTS); + if (!selectedComponentId && activeTab !== RIGHT_SIDE_BAR_TAB.PAGES) { + // return setActiveRightSideBarTab(RIGHT_SIDE_BAR_TAB.COMPONENTS); + return ( + <> +
+
Component properties
+
toggleRightSidebarPin()}> + +
+
+
+ +
No component selected
+
+ Click a component on the canvas to view and edit its properties. +
+
+ + ); } return ( { const _shouldFreeze = useStore((state) => state.getShouldFreeze()); const isAutoMobileLayout = useStore((state) => state.currentLayout === 'mobile' && state.getIsAutoMobileLayout()); const shouldFreeze = _shouldFreeze || isAutoMobileLayout; - + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const handleSearchQueryChange = useCallback( - debounce((e) => { - const { value } = e.target; + debounce((value) => { setSearchQuery(value); - if (activeTab === 1) { filterComponents(value); } @@ -78,11 +80,10 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { ); } - function renderList(header, items) { + function renderList(items) { if (isEmpty(items)) return null; return (
- {header}
{items.map((component, i) => renderComponentCard(component, i))}
@@ -105,6 +106,7 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { className=" btn-sm tj-tertiary-btn mt-3" onClick={() => { setFilteredComponents([]); + handleSearchQueryChange(''); }} > {t('widgetManager.clearQuery', 'clear query')} @@ -113,62 +115,31 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { ); } - if (filteredComponents.length != componentList.length) { - return <>{renderList(undefined, filteredComponents)}; - } else { - const commonSection = { title: t('widgetManager.commonlyUsed', 'commonly used'), items: [] }; - const layoutsSection = { title: t('widgetManager.layouts', 'layouts'), items: [] }; - const formSection = { title: t('widgetManager.forms', 'forms'), items: [] }; - const integrationSection = { title: t('widgetManager.integrations', 'integrations'), items: [] }; - const otherSection = { title: t('widgetManager.others', 'others'), items: [] }; - const legacySection = { title: 'Legacy', items: [] }; - - const commonItems = ['Table', 'Button', 'Text', 'TextInput', 'DatetimePickerV2', 'Form']; - const formItems = [ - 'Form', - 'TextInput', - 'NumberInput', - 'PasswordInput', - 'TextArea', - 'EmailInput', - 'PhoneInput', - 'CurrencyInput', - 'ToggleSwitchV2', - 'DropdownV2', - 'MultiselectV2', - 'RichTextEditor', - 'Checkbox', - 'RadioButtonV2', - 'DatetimePickerV2', - 'DatePickerV2', - 'TimePicker', - 'DaterangePicker', - 'FilePicker', - 'StarRating', - ]; - const integrationItems = ['Map']; - const layoutItems = ['Container', 'Listview', 'Tabs', 'ModalV2']; - - filteredComponents.forEach((f) => { - if (commonItems.includes(f)) commonSection.items.push(f); - if (formItems.includes(f)) formSection.items.push(f); - else if (integrationItems.includes(f)) integrationSection.items.push(f); - else if (LEGACY_ITEMS.includes(f)) legacySection.items.push(f); - else if (layoutItems.includes(f)) layoutsSection.items.push(f); - else otherSection.items.push(f); - }); - - return ( - <> - {renderList(commonSection.title, commonSection.items)} - {renderList(layoutsSection.title, layoutsSection.items)} - {renderList(formSection.title, formSection.items)} - {renderList(otherSection.title, otherSection.items)} - {renderList(integrationSection.title, integrationSection.items)} - {renderList(legacySection.title, legacySection.items)} - - ); + if (filteredComponents.length !== componentList.length) { + return <>{renderList(filteredComponents)}; } + + const sections = Object.entries(sectionConfig).map(([key, config]) => ({ + title: config.title, + items: filteredComponents.filter((component) => config.valueSet.has(component)), + })); + + const items = []; + sections.forEach((section) => { + if (section.items.length > 0) { + items.push({ + title: section.title, + isOpen: true, + children: renderList(section.items), + }); + } + }); + + return ( +
+ +
+ ); } const handleChangeTab = (tab) => { @@ -195,7 +166,7 @@ export const ComponentsManagerTab = ({ darkMode, isModuleEditor }) => { handleSearchQueryChange(e)} + callBack={(e) => handleSearchQueryChange(e.target.value)} onClearCallback={() => { setSearchQuery(''); if (activeTab === 1) { diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx index 0c6f7327af..1b765e160d 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx +++ b/frontend/src/AppBuilder/RightSideBar/ComponentManagerTab/DragLayer.jsx @@ -11,6 +11,11 @@ import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { noop } from 'lodash'; export const DragLayer = ({ index, component, isModuleTab = false }) => { + const [isRightSidebarOpen, toggleRightSidebar] = useStore( + (state) => [state.isRightSidebarOpen, state.toggleRightSidebar], + shallow + ); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const { isModuleEditor } = useModuleContext(); const setShowModuleBorder = useStore((state) => state.setShowModuleBorder, shallow) || noop; const [{ isDragging }, drag, preview] = useDrag( @@ -28,11 +33,14 @@ export const DragLayer = ({ index, component, isModuleTab = false }) => { useEffect(() => { if (isDragging && !isModuleEditor) { + if (!isRightSidebarPinned) { + toggleRightSidebar(!isRightSidebarOpen); + } setShowModuleBorder(true); } else { setShowModuleBorder(false); } - }, [isDragging, setShowModuleBorder, isModuleEditor]); + }, [isDragging, setShowModuleBorder, isModuleEditor, toggleRightSidebar]); // const size = isModuleTab // ? component.module_container.layouts[currentLayout] @@ -55,36 +63,43 @@ const CustomDragLayer = ({ size }) => { currentOffset: monitor.getSourceClientOffset(), item: monitor.getItem(), })); - + console.log(currentOffset, 'currentOffset'); if (!currentOffset) return null; const canvasWidth = item?.canvasWidth; const canvasBounds = item?.canvasRef?.getBoundingClientRect(); const height = size.height; - const mainCanvasWidth = document.getElementById('real-canvas')?.offsetWidth || 0; + const appCanvasWidth = document.getElementById('real-canvas')?.offsetWidth || 0; + + // Calculate width based on the app canvas's grid + let width = (appCanvasWidth * size.width) / NO_OF_GRIDS; - let width = (mainCanvasWidth * size.width) / NO_OF_GRIDS; // Calculate position relative to the current canvas (parent or child) const left = currentOffset.x - (canvasBounds?.left || 0); const top = currentOffset.y - (canvasBounds?.top || 0); - // Adjust position and width if exceeding grid bounds - if (width >= canvasWidth) { + // Ensure width doesn't exceed the current container's width + if (width > canvasWidth) { width = canvasWidth; } + // Snap width to grid (round to nearest grid unit) + const gridUnitWidth = canvasWidth / NO_OF_GRIDS; + const gridUnits = Math.round(width / gridUnitWidth); + width = gridUnits * gridUnitWidth; + const [x, y] = snapToGrid(canvasWidth, left, top); return (
{ + const [isFxActive, setIsFxActive] = useState(false); + + const handleFxButtonClick = () => { + paramUpdated({ name: paramName }, 'fxActive', !isFxActive, 'properties'); + setIsFxActive(!isFxActive); + }; + + return ( +
e.stopPropagation()} + > +
e.stopPropagation()}> +
+ +
+ +
+
+ {isFxActive ? ( + + ) : ( + +
+ + {/* Mandatory Checkbox */} +
+ +
+
+ ); +}; + +const RenderSection = ({ + mappedColumns = [], + setMappedColumns, + darkMode, + sectionType, + sectionDisplayName, + disabled = false, +}) => { + const columnsArray = useMemo(() => { + return Array.isArray(mappedColumns) ? mappedColumns : []; + }, [mappedColumns]); + + const checkboxStates = useCheckboxStates(columnsArray); + + const { isAllSelected, isIntermediateSelected, isAllSelectedMandatory, isIntermediateMandatory } = checkboxStates; + + const handleSelectAll = useCallback( + (checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => ({ + ...col, + selected: checked, + })); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleSelectAllMandatory = useCallback( + (checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => { + if (isPropertyFxControlled(col.mandatory)) { + return col; + } + + return { + ...col, + mandatory: { + ...col.mandatory, + value: checked, + }, + }; + }); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleColumnSelect = useCallback( + (columnName, checked) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => { + if (col.name !== columnName) { + return col; + } + + return { + ...col, + selected: checked, + }; + }); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const handleColumnChange = useCallback( + (columnName, changes) => { + if (columnsArray.length > 0) { + const updatedColumns = columnsArray.map((col) => (col.name === columnName ? { ...col, ...changes } : col)); + setMappedColumns(updatedColumns); + } + }, + [columnsArray, setMappedColumns] + ); + + const shouldHideSelectAll = sectionType === 'isCustomField'; + + const renderHeader = () => { + return ( +
+
+ +
+
+ Column name +
+
+
+ + Mapped to +
+
+ + Input label +
+
+ Mandatory? +
+ +
+
+
+ ); + }; + + return ( +
+
+ {sectionDisplayName} +
+ + {renderHeader()} + +
+ {columnsArray.length > 0 ? ( + columnsArray.map((column, index) => ( + c.name === column.name)} + onCheckboxChange={(checked) => handleColumnSelect(column.name, checked)} + onChange={(changes) => handleColumnChange(column.name, changes)} + index={index} + darkMode={darkMode} + disabled={disabled} + sectionType={sectionType} + /> + )) + ) : ( +
No {sectionDisplayName.toLowerCase()} available
+ )} +
+
+ ); +}; + +const ColumnMappingComponent = ({ + isOpen, + onClose, + darkMode = false, + onSubmit, + currentStatusRef, + component, + newResolvedJsonData, + existingResolvedJsonData, + source, + isDataLoading, +}) => { + const { resolveReferences, getComponentDefinition, getFormFields } = useStore( + (state) => ({ + resolveReferences: state.resolveReferences, + getComponentDefinition: state.getComponentDefinition, + getFormFields: state.getFormFields, + }), + shallow + ); + + const componentNameIdMapping = useStore((state) => state.modules.canvas.componentNameIdMapping, shallow); + const queryNameIdMapping = useStore((state) => state.modules.canvas.queryNameIdMapping, shallow); + const runQuery = useStore((state) => state.queryPanel.runQuery, shallow); + + const [isSaving, setIsSaving] = useState(false); + const [refreshedColumns, setRefreshedColumns] = useState([]); + const [showLoader, setShowLoader] = useState(false); + const bodyContainerRef = useRef(null); + const lastBodyHeightRef = useRef(60); + + useEffect(() => { + setShowLoader(isDataLoading); + }, [isDataLoading]); + + // Track body height when content is loaded + useEffect(() => { + if (!showLoader && bodyContainerRef.current) { + // Use setTimeout to ensure DOM is fully rendered + setTimeout(() => { + if (bodyContainerRef.current) { + const height = bodyContainerRef.current.scrollHeight; + if (height > 0) { + lastBodyHeightRef.current = height; + } + } + }, 0); + } + }, [showLoader, groupedColumns]); + + const currentStatus = currentStatusRef.current; + + console.log('here--- existingResolvedJsonData--- ', existingResolvedJsonData); + + const columnsToUse = useColumnBuilder( + component, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns?.length === 0 || Object.keys(refreshedColumns).length === 0 + ? newResolvedJsonData + : refreshedColumns, + getFormFields, + getComponentDefinition + ); + + const { groupedColumns, sectionTypes, updateSectionColumns } = useGroupedColumns(columnsToUse, currentStatus); + + const refreshData = useCallback(async () => { + setShowLoader(true); + currentStatusRef.current = FORM_STATUS.REFRESH_FIELDS; + const res = extractAndReplaceReferencesFromString(source.value, componentNameIdMapping, queryNameIdMapping); + const { allRefs, valueWithBrackets } = res; + + const queryRefs = allRefs + .filter((ref) => ref.entityType === 'queries') + .filter((ref, index, self) => index === self.findIndex((r) => r.entityNameOrId === ref.entityNameOrId)); + + await Promise.all( + queryRefs.map(async (ref) => { + const queryId = ref.entityNameOrId; + await runQuery(queryId, '', false, 'edit'); + }) + ); + + const resolvedValue = resolveReferences('canvas', valueWithBrackets); + setRefreshedColumns(resolvedValue); + setShowLoader(false); + }, [source.value, componentNameIdMapping, queryNameIdMapping, runQuery, resolveReferences, currentStatusRef]); + + const handleSubmit = useCallback(() => { + setIsSaving(true); + const flatColumns = Object.entries(groupedColumns) + .flatMap(([, columns]) => columns) + .filter((col) => !col.isCustomField); + const combinedColumns = flatColumns.map((column) => { + if (!column.selected) { + return { + ...column, + isRemoved: true, + }; + } else return column; + }); + + onSubmit?.(combinedColumns); + }, [groupedColumns, onSubmit]); + + // Get display name for section type + const getSectionDisplayName = useCallback((sectionType) => { + return SECTION_DISPLAY_NAMES[sectionType] || ''; + }, []); + + const allSectionsEmpty = useMemo(() => { + return Object.values(groupedColumns).every((sectionColumns) => { + return Array.isArray(sectionColumns) ? sectionColumns.every((col) => !col.selected) : true; + }); + }, [groupedColumns]); + + const modalBody = ( + <> +
+ {showLoader && } + + {!showLoader && ( +
+ {sectionTypes.map((sectionType) => { + return ( + groupedColumns[sectionType]?.length > 0 && ( + updateSectionColumns(sectionType, updatedColumns)} + darkMode={darkMode} + sectionType={sectionType} + sectionDisplayName={ + currentStatus !== FORM_STATUS.GENERATE_FIELDS ? getSectionDisplayName(sectionType) : '' + } + disabled={sectionType === 'isRemoved'} + /> + ) + ); + })} +
+ )} +
+
+ +
+ + ); + + return ( + + +
{modalBody}
+
+ ); +}; + +export default ColumnMappingComponent; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx new file mode 100644 index 0000000000..aca6ee612f --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSection.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { renderElement } from '../../../Utils'; +import { DataSectionWrapper } from './index'; + +export const DataSection = ({ + component, + componentMeta, + paramUpdatedInterceptor, + dataQueries, + currentState, + allComponents, + darkMode, + resolvedCustomSchema, + source, + JSONData, + setCodeEditorView, + currentStatusRef, + saveDataSection, + openModal, + setParentModalState, + performColumnMapping, + existingResolvedJsonData, + savedSourceValue, + resolveReferences, + isLoading = false, +}) => { + return () => ( +
+ {componentMeta?.properties && + Object.keys(componentMeta.properties).map((property) => { + if (componentMeta?.properties[property]?.section !== 'data') return null; + + // Mutating the component definition properties to set the generateFormFrom source + component.component.definition.properties.generateFormFrom = source; + component.component.definition.properties.JSONData = JSONData; + const focusCodeEditor = property === 'JSONData' ? setCodeEditorView : undefined; + + return renderElement( + component, + componentMeta, + paramUpdatedInterceptor, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode, + '', + null, + focusCodeEditor + ); + })} + {source.value !== 'jsonSchema' && ( + + )} +
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx new file mode 100644 index 0000000000..c557644440 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/DataSectionUI.jsx @@ -0,0 +1,201 @@ +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import { Button } from '@/components/ui/Button/Button'; +import { LabeledDivider, ColumnMappingComponent, FormFieldsList, FieldPopoverContent } from './index'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import { useDropdownState } from '../_hooks/useDropdownState'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { findNextElementTop, mergeFieldsWithComponentDefinition } from '../utils/utils'; +import { createNewComponentFromMeta } from '../utils/fieldOperations'; +import { FORM_STATUS, COMPONENT_LAYOUT_DETAILS } from '../constants'; +import { checkDiff } from '@/AppBuilder/Widgets/componentUtils'; + +/* IMPORTANT - mandatory and selected (visibility) properties are objects with value and fxActive + This is to support dynamic values and fx expressions in the form fields. + When using these properties, ensure to access the value like so: field.mandatory.value + or field.selected.value. + Rest all the fields are directly accessible as strings or booleans. + For example: field.label, field.name, field.value, etc. +*/ + +const DataSectionUI = ({ + component, + darkMode = false, + currentStatusRef, + openModalFromParent = false, + setParentModalState, + performColumnMapping, + newResolvedJsonData, + existingResolvedJsonData, + source, + JSONData, + isLoading: isDataLoading, + savedSourceValue = '', +}) => { + const { getChildComponents, currentLayout, getComponentDefinition, performBatchComponentOperations, saveFormFields } = + useStore( + (state) => ({ + getChildComponents: state.getChildComponents, + currentLayout: state.currentLayout, + getComponentDefinition: state.getComponentDefinition, + performBatchComponentOperations: state.performBatchComponentOperations, + saveFormFields: state.saveFormFields, + }), + shallow + ); + + const formFields = useStore((state) => state.getFormFields(component.id), checkDiff); + + const formFieldsWithComponentDefinition = useMemo( + () => mergeFieldsWithComponentDefinition(formFields, getComponentDefinition), + [formFields, getComponentDefinition] + ); + + const { handleDropdownOpen, handleDropdownClose, shouldPreventPopoverClose } = useDropdownState(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [showAddFieldPopover, setShowAddFieldPopover] = useState(false); + const addFieldButtonRef = useRef(null); + + const hideManageFields = formFields.length === 0 || savedSourceValue === 'rawJson'; + + useEffect(() => { + if (openModalFromParent && openModalFromParent !== isModalOpen) { + setIsModalOpen(true); + } else if (!openModalFromParent) setIsModalOpen(false); + }, [openModalFromParent, isModalOpen]); + + const handleDeleteField = (field) => { + const updatedFields = formFields.filter((f) => f.componentId !== field.componentId); + let operations = { + updated: {}, + added: {}, + deleted: [field.componentId], + }; + performBatchComponentOperations(operations); + saveFormFields(component.id, updatedFields, 'canvas'); + }; + + const handleAddField = (newField) => { + const updatedFields = { + componentType: newField.componentType, + name: 'custom', + mandatory: newField.mandatory, + label: newField.label, + value: '', + placeholder: newField.placeholder, + selected: true, + isCustomField: true, + }; + const childComponents = getChildComponents(component?.id); + // Get the last position of the child components + const nextElementsTop = findNextElementTop(childComponents, currentLayout); + const { added = {} } = createNewComponentFromMeta( + updatedFields, + component.id, + nextElementsTop + COMPONENT_LAYOUT_DETAILS.spacing + ); + let operations = { + updated: {}, + added: {}, + deleted: [], + }; + operations.added[added.id] = added; + + performBatchComponentOperations(operations); + saveFormFields(component.id, [...formFields, { componentId: added.id, isCustomField: true }], 'canvas'); + setShowAddFieldPopover(false); + }; + + const renderManageFieldsIcon = () => { + return ( +
+ + +
+
+ { + handleFieldChange('componentType', value); + }} + width="100%" + label="Component" + onOpen={onDropdownOpen} + onClose={onDropdownClose} + /> +
+ +
+ + handleFieldChange('label', value)} + /> +
+ + {renderPlaceholder()} + {renderDefaultValue()} + +
+ handleFieldChange('mandatory', value)} + onFxPress={(active) => handleFxChange('mandatory', active)} + /> +
+ {mode === 'edit' && ( +
+ handleFieldChange('visibility', value)} + onFxPress={(active) => handleFxChange('visibility', active)} + /> +
+ )} + +
+
+ + ); +}; + +export default React.memo(FieldPopoverContent); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx new file mode 100644 index 0000000000..eadff717f9 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormField.jsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/Button/Button'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import { FieldPopoverContent } from './index'; +import { useDropdownState } from '../_hooks/useDropdownState'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { isTrueValue, isPropertyFxControlled, getComponentIcon } from '../utils/utils'; + +export const FormField = ({ field, onDelete, activeMenu, onMenuToggle, onSave, darkMode = false }) => { + const setSelectedComponents = useStore((state) => state.setSelectedComponents, shallow); + const [showPopover, setShowPopover] = useState(false); + const [fieldData, setFieldData] = useState(field); + const { handleDropdownOpen, handleDropdownClose, shouldPreventPopoverClose } = useDropdownState(); + + useEffect(() => { + if (activeMenu && activeMenu !== fieldData.name) { + setShowPopover(false); + } + }, [activeMenu, fieldData.name]); + + useEffect(() => { + setFieldData(field); + }, [field]); + + const handleFieldChange = (updatedField) => { + setFieldData(updatedField); + onSave([updatedField], true); + }; + + const isMandatoryFxControlled = isPropertyFxControlled(fieldData.mandatory); + + const isCurrentlyMandatory = isTrueValue(fieldData.mandatory?.value); + + const mainPopover = ( + + setShowPopover(false)} + onChange={handleFieldChange} + onDropdownOpen={handleDropdownOpen} + onDropdownClose={handleDropdownClose} + shouldPreventPopoverClose={shouldPreventPopoverClose} + setSelectedComponents={setSelectedComponents} + /> + + ); + + const menuPopover = ( + + +
+ + + +
+
+
+ ); + + return ( +
+ { + if (!show && shouldPreventPopoverClose) { + return; + } + if (show) onMenuToggle(null); + setShowPopover(show); + }} + rootClose + overlay={mainPopover} + > +
+
+
+ {getComponentIcon(fieldData.componentType, darkMode)} +
+ {fieldData.name} +
+ + { + setShowPopover(false); + if (show) { + onMenuToggle(fieldData.name); + } else { + onMenuToggle(null); + } + }} + rootClose + overlay={menuPopover} + > +
+
+
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx new file mode 100644 index 0000000000..12a8856673 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/FormFieldsList.jsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { FormField } from './index'; + +export const FormFieldsList = ({ fields, onDeleteField, currentStatusRef, onSave }) => { + const [activeMenuField, setActiveMenuField] = useState(null); + + if (fields.length === 0) { + return ( + + No fields yet. Generate a form from a data source or add custom fields. + + ); + } + + return ( +
+
+
+ {fields.map((field) => ( + { + currentStatusRef.current = null; + setActiveMenuField(fieldName); + }} + onDelete={onDeleteField} + onSave={onSave} + /> + ))} +
+
+
+ ); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx new file mode 100644 index 0000000000..7e2ffb293d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/LabeledDivider.jsx @@ -0,0 +1,39 @@ +import React from 'react'; + +const LabeledDivider = ({ label, rightContentCount = 0 }) => { + return ( +
+ {/* Background line */} +
+
+
+ + {/* Label container - centered accounting for right content */} +
+ + {label} + +
+
+ ); +}; + +export default LabeledDivider; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js new file mode 100644 index 0000000000..fb0aee7ca7 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/hooks/useColumnMapping.js @@ -0,0 +1,144 @@ +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { + isTrueValue, + isPropertyFxControlled, + parseDataAndBuildFields, + analyzeJsonDifferences, + mergeFieldsWithComponentDefinition, + mergeFormFieldsWithNewData, + mergeArrays, +} from '../../utils/utils'; +import { FORM_STATUS } from '../../constants'; +import { merge } from 'lodash'; + +// Constants for section order preference +const SECTION_ORDER = ['isNew', 'isRemoved', 'existing', 'isCustomField']; + +/** + * Custom hook for managing column building logic + */ +export const useColumnBuilder = ( + component, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns, + getFormFields, + getComponentDefinition +) => { + return useMemo(() => { + const formFields = getFormFields(component.id); + const formFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition(formFields, getComponentDefinition); + + if (currentStatus === FORM_STATUS.MANAGE_FIELDS) { + const allColumnsFromJsonData = parseDataAndBuildFields(newResolvedJsonData); + return mergeArrays(allColumnsFromJsonData, formFieldsWithComponentDefinition); + } else if (currentStatus === FORM_STATUS.REFRESH_FIELDS) { + const jsonDifferences = analyzeJsonDifferences(refreshedColumns, existingResolvedJsonData); + const mergedJsonData = merge({}, existingResolvedJsonData, refreshedColumns); + const parsedFields = parseDataAndBuildFields(mergedJsonData, jsonDifferences); + const mergedFields = mergeFormFieldsWithNewData(formFieldsWithComponentDefinition, parsedFields); + const enhancedFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition( + mergedFields, + getComponentDefinition + ); + return [ + ...enhancedFieldsWithComponentDefinition, + ...formFieldsWithComponentDefinition.filter((f) => f.isCustomField), + ]; + } + return parseDataAndBuildFields(newResolvedJsonData || []); + }, [ + component.id, + currentStatus, + newResolvedJsonData, + existingResolvedJsonData, + refreshedColumns, + getFormFields, + getComponentDefinition, + ]); +}; + +/** + * Custom hook for managing grouped columns state + */ +export const useGroupedColumns = (columnsToUse, currentStatus) => { + const [groupedColumns, setGroupedColumns] = useState({}); + const [sectionTypes, setSectionTypes] = useState([]); + + useEffect(() => { + const grouped = {}; + const isGenerateFieldsMode = currentStatus === FORM_STATUS.GENERATE_FIELDS; + const isRefreshFormMode = currentStatus === FORM_STATUS.REFRESH_FIELDS; + const shouldSelectByDefault = isGenerateFieldsMode || isRefreshFormMode; + + columnsToUse.forEach((col) => { + let sectionType = 'existing'; + + if (col.isNew) { + sectionType = 'isNew'; + } else if (col.isRemoved) { + sectionType = 'isRemoved'; + } else if (col.isCustomField) { + sectionType = 'isCustomField'; + } + + if (!grouped[sectionType]) { + grouped[sectionType] = []; + } + + // Auto-select columns based on mode + if ( + shouldSelectByDefault && + sectionType !== 'isRemoved' && + (isGenerateFieldsMode || (isRefreshFormMode && sectionType === 'isNew')) + ) { + grouped[sectionType].push({ ...col, selected: true }); + } else { + grouped[sectionType].push(col); + } + }); + + const types = SECTION_ORDER.filter((type) => grouped[type] && grouped[type].length > 0); + + setGroupedColumns(grouped); + setSectionTypes(types); + }, [columnsToUse, currentStatus]); + + const updateSectionColumns = useCallback((sectionType, updatedColumns) => { + setGroupedColumns((prev) => ({ + ...prev, + [sectionType]: updatedColumns, + })); + }, []); + + return { groupedColumns, sectionTypes, updateSectionColumns }; +}; + +/** + * Hook for checkbox state calculations + */ +export const useCheckboxStates = (columnsArray) => { + return useMemo(() => { + const mandatorySettableColumns = columnsArray.filter((col) => !isPropertyFxControlled(col.mandatory)); + + const isAllSelected = columnsArray.length > 0 ? columnsArray.every((col) => col.selected) : false; + const isIntermediateSelected = !isAllSelected && columnsArray.some((col) => col.selected); + + const isAllSelectedMandatory = + mandatorySettableColumns.length > 0 + ? mandatorySettableColumns.every((col) => isTrueValue(col.mandatory.value)) + : false; + + const isIntermediateMandatory = + !isAllSelectedMandatory && mandatorySettableColumns.some((col) => isTrueValue(col.mandatory.value)); + + return { + isAllSelected, + isIntermediateSelected, + isAllSelectedMandatory, + isIntermediateMandatory, + mandatorySettableColumns, + }; + }, [columnsArray]); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js new file mode 100644 index 0000000000..682f2d4533 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_components/index.js @@ -0,0 +1,9 @@ +// Component exports for cleaner imports +export { DataSection } from './DataSection'; +export { default as DataSectionWrapper } from './DataSectionWrapper'; +export { default as DataSectionUI } from './DataSectionUI'; +export { default as ColumnMappingComponent } from './ColumnMappingComponent'; +export { default as FieldPopoverContent } from './FieldPopoverContent'; +export { FormField } from './FormField'; +export { FormFieldsList } from './FormFieldsList'; +export { default as LabeledDivider } from './LabeledDivider'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js new file mode 100644 index 0000000000..4a70ccee82 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/index.js @@ -0,0 +1,4 @@ +export { useFormState } from './useFormState'; +export { useFormData } from './useFormData'; +export { useFormLogic } from './useFormLogic'; +export { useDropdownState } from './useDropdownState'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js new file mode 100644 index 0000000000..4cc35aee7a --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useDropdownState.js @@ -0,0 +1,23 @@ +import { useState, useCallback } from 'react'; + +export const useDropdownState = () => { + const [dropdownState, setDropdownState] = useState('closed'); // 'closed' | 'opening' | 'open' + + const handleDropdownOpen = useCallback(() => { + setDropdownState('open'); + }, []); + + const handleDropdownClose = useCallback(() => { + setDropdownState('closing'); + setTimeout(() => setDropdownState('closed'), 100); + }, []); + + const shouldPreventPopoverClose = dropdownState !== 'closed'; + + return { + dropdownState, + handleDropdownOpen, + handleDropdownClose, + shouldPreventPopoverClose, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js new file mode 100644 index 0000000000..35e277850e --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormData.js @@ -0,0 +1,37 @@ +import React from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { mergeFieldsWithComponentDefinition } from '../utils/utils'; + +export const useFormData = (component) => { + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + const getFormDataSectionData = useStore((state) => state.getFormDataSectionData, shallow); + const getComponentDefinition = useStore((state) => state.getComponentDefinition, shallow); + const formFields = useStore((state) => state.getFormFields(component.id), shallow); + + // Get form data and process it + const existingData = getFormDataSectionData(component?.id); + let isFormGenerated = existingData?.generateFormFrom?.value ?? false; + + // Memoized form fields with component definition + const formFieldsWithComponentDefinition = React.useMemo( + () => mergeFieldsWithComponentDefinition(formFields, getComponentDefinition), + [formFields, getComponentDefinition] + ); + + // Process JSON data + let existingResolvedJsonData = existingData?.JSONData?.value; + existingResolvedJsonData = resolveReferences('canvas', existingResolvedJsonData); + + const newJSONValue = component.component.definition.properties['JSONData']?.value; + const newResolvedJsonData = resolveReferences('canvas', newJSONValue); + + return { + existingData, + isFormGenerated, + formFieldsWithComponentDefinition, + existingResolvedJsonData, + newJSONValue, + newResolvedJsonData, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js new file mode 100644 index 0000000000..0484f64f26 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormLogic.js @@ -0,0 +1,114 @@ +import { useEffect } from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { useFormState } from './useFormState'; +import { useFormData } from './useFormData'; +import { createParamUpdatedInterceptor, createColumnMappingHandler, createJSONDataBlurHandler } from '../handlers'; + +export const useFormLogic = (component, paramUpdated) => { + // Store selectors + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + const getFormDataSectionData = useStore((state) => state.getFormDataSectionData, shallow); + const saveFormDataSectionData = useStore((state) => state.saveFormDataSectionData, shallow); + const componentNameIdMapping = useStore((state) => state.modules.canvas.componentNameIdMapping, shallow); + const queryNameIdMapping = useStore((state) => state.modules.canvas.queryNameIdMapping, shallow); + const getChildComponents = useStore((state) => state.getChildComponents, shallow); + const runQuery = useStore((state) => state.queryPanel.runQuery, shallow); + const getExposedValueOfQuery = useStore((state) => state.getExposedValueOfQuery, shallow); + const currentLayout = useStore((state) => state.currentLayout, shallow); + const getComponentDefinition = useStore((state) => state.getComponentDefinition, shallow); + const performBatchComponentOperations = useStore((state) => state.performBatchComponentOperations, shallow); + + // Custom hooks + const formState = useFormState(component); + const formData = useFormData(component); + + // Save data section function + const saveDataSection = (fields) => { + formState.savedSourceValue.current = formState.source.value; + const newJsonData = formState.JSONData; + + if (newJsonData?.value === undefined) { + newJsonData.value = resolveReferences('canvas', formState.source.value); + } + + saveFormDataSectionData( + component?.id, + { + generateFormFrom: formState.source, + JSONData: formState.JSONData, + }, + fields + ); + }; + + // Create column mapping handler + const performColumnMapping = createColumnMappingHandler({ + component, + isFormGenerated: formData.isFormGenerated, + currentStatusRef: formState.currentStatusRef, + formFields: useStore((state) => state.getFormFields(component.id), shallow), + formFieldsWithComponentDefinition: formData.formFieldsWithComponentDefinition, + getChildComponents, + currentLayout, + performBatchComponentOperations, + saveDataSection, + setOpenModal: formState.setOpenModal, + }); + + // Create JSON data blur handler + const handleJSONDataBlur = createJSONDataBlurHandler({ + component, + currentStatusRef: formState.currentStatusRef, + resolveReferences, + getFormDataSectionData, + savedSourceValue: formState.savedSourceValue, + source: formState.source, + formFieldsWithComponentDefinition: formData.formFieldsWithComponentDefinition, + existingResolvedJsonData: formData.existingResolvedJsonData, + getComponentDefinition, + performColumnMapping, + saveDataSection, + codeEditorView: formState.codeEditorView, + }); + + // Create parameter updated interceptor + const paramUpdatedInterceptor = createParamUpdatedInterceptor({ + component, + paramUpdated, + source: formState.source, + setSource: formState.setSource, + setJSONData: formState.setJSONData, + setOpenModal: formState.setOpenModal, + shouldFocusJSONDataEditor: formState.shouldFocusJSONDataEditor, + shouldInvokeBlurEvent: formState.shouldInvokeBlurEvent, + savedSourceValue: formState.savedSourceValue, + componentNameIdMapping, + queryNameIdMapping, + getFormDataSectionData, + getExposedValueOfQuery, + runQuery, + resolveReferences, + setLoading: formState.setLoading, + }); + + // Effect for handling JSON data blur + useEffect(() => { + if (formState.shouldInvokeBlurEvent.current) { + formState.shouldInvokeBlurEvent.current = false; + handleJSONDataBlur(formState.JSONData.value); + } + }, [formState.shouldInvokeBlurEvent, formState.JSONData, handleJSONDataBlur]); + + return { + ...formState, + ...formData, + paramUpdatedInterceptor, + performColumnMapping, + handleJSONDataBlur, + saveDataSection, + closeModal: () => { + formState.setOpenModal(false); + }, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js new file mode 100644 index 0000000000..39104b0909 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/_hooks/useFormState.js @@ -0,0 +1,78 @@ +import { useState, useRef, useEffect } from 'react'; +import useStore from '@/AppBuilder/_stores/store'; +import { shallow } from 'zustand/shallow'; +import { INPUT_COMPONENTS_FOR_FORM } from '../constants'; + +export const useFormState = (component) => { + const getChildComponents = useStore((state) => state.getChildComponents, shallow); + const saveFormFields = useStore((state) => state.saveFormFields, shallow); + const resolveReferences = useStore((state) => state.resolveReferences, shallow); + + const [source, setSource] = useState({ + value: component.component.definition.properties?.generateFormFrom?.value, + fxActive: component.component.definition.properties?.generateFormFrom?.fxActive, + }); + + const resolvedSource = resolveReferences( + 'canvas', + component.component.definition.properties?.generateFormFrom?.value + ); + + const [JSONData, setJSONData] = useState({ + value: resolvedSource === 'rawJson' ? component.component.definition.properties?.JSONData?.value : resolvedSource, + }); + + const [openModal, setOpenModal] = useState(false); + const [isLoading, setLoading] = useState(false); + const [codeEditorView, setCodeEditorView] = useState(null); + + // Refs for managing component state + const shouldFocusJSONDataEditor = useRef(false); + const currentStatusRef = useRef(null); + const shouldInvokeBlurEvent = useRef(false); + const savedSourceValue = useRef(component.component.definition.properties?.generateFormFrom?.value); + + // Backfill fields if not present + const fields = component.component.definition.properties?.fields; + if (fields === undefined) { + const newFields = []; + const childComponents = getChildComponents(component.id); + Object.keys(childComponents).forEach((childId) => { + if (INPUT_COMPONENTS_FOR_FORM.includes(childComponents[childId].component.component.component)) { + newFields.push({ + componentId: childId, + isCustomField: true, + }); + } + }); + saveFormFields(component.id, newFields, 'canvas'); + } + + // Focus management effect + useEffect(() => { + if (codeEditorView && shouldFocusJSONDataEditor.current) { + codeEditorView.focus(); + // Add 'focused' class to the parent of codeEditorView.dom + if (codeEditorView.dom && codeEditorView.dom.parentNode) { + codeEditorView.dom.parentNode.classList.add('focused'); + } + } + }, [codeEditorView, shouldFocusJSONDataEditor]); + + return { + source, + setSource, + JSONData, + setJSONData, + openModal, + setOpenModal, + codeEditorView, + setCodeEditorView, + shouldFocusJSONDataEditor, + currentStatusRef, + shouldInvokeBlurEvent, + savedSourceValue, + isLoading, + setLoading, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js similarity index 51% rename from frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx rename to frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js index 6b0bc05422..cb630bbcc4 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/config/accordionConfig.js @@ -1,93 +1,10 @@ import React from 'react'; -import Accordion from '@/_ui/Accordion'; -import { EventManager } from '../EventManager'; -import { renderElement } from '../Utils'; // eslint-disable-next-line import/no-unresolved import i18next from 'i18next'; -import { deepClone } from '@/_helpers/utilities/utils.helpers'; +import { EventManager } from '../../../EventManager'; +import { renderElement } from '../../../Utils'; -export const Form = ({ - componentMeta, - darkMode, - layoutPropertyChanged, - component, - paramUpdated, - dataQueries, - currentState, - eventsChanged, - apps, - allComponents, - pages, -}) => { - const tempComponentMeta = deepClone(componentMeta); - - let properties = []; - let additionalActions = []; - let dataProperties = []; - - const events = Object.keys(componentMeta.events); - const validations = Object.keys(componentMeta.validation || {}); - - for (const [key] of Object.entries(componentMeta?.properties)) { - if (componentMeta?.properties[key]?.section === 'additionalActions') { - additionalActions.push(key); - } else if (componentMeta?.properties[key]?.accordian === 'Data') { - dataProperties.push(key); - } else { - properties.push(key); - } - } - - const { id } = component; - const newOptions = [{ name: 'None', value: 'none' }]; - - Object.entries(allComponents).forEach(([componentId, _component]) => { - const validParent = - _component.component.parent === id || - _component.component.parent === `${id}-footer` || - _component.component.parent === `${id}-header`; - if (validParent && _component?.component?.component === 'Button') { - newOptions.push({ name: _component.component.name, value: componentId }); - } - }); - - tempComponentMeta.properties.buttonToSubmit.options = newOptions; - - // Hide header footer if custom schema is turned on - - if (component.component.definition.properties.advanced.value === '{{true}}') { - component.component.properties.showHeader = { - ...component.component.properties.headerHeight, - isHidden: true, - }; - component.component.properties.showFooter = { - ...component.component.properties.headerHeight, - isHidden: true, - }; - } - - const accordionItems = baseComponentProperties( - properties, - events, - component, - tempComponentMeta, - layoutPropertyChanged, - paramUpdated, - dataQueries, - currentState, - eventsChanged, - apps, - allComponents, - validations, - darkMode, - pages, - additionalActions - ); - - return ; -}; - -export const baseComponentProperties = ( +export const createAccordionItems = ({ properties, events, component, @@ -102,12 +19,16 @@ export const baseComponentProperties = ( validations, darkMode, pages, - additionalActions -) => { + additionalActions, + deprecatedProperties, + renderDataElement, +}) => { let items = []; + + // Structure section if (properties.length > 0) { items.push({ - title: `${i18next.t('widget.common.properties', 'Properties')}`, + title: `${i18next.t('widget.common.structure', 'Structure')}`, children: properties.map((property) => renderElement( component, @@ -124,6 +45,14 @@ export const baseComponentProperties = ( }); } + // Data section + items.push({ + title: 'Data', + isOpen: true, + children: renderDataElement(), + }); + + // Events section if (events.length > 0) { items.push({ title: `${i18next.t('widget.common.events', 'Events')}`, @@ -145,6 +74,7 @@ export const baseComponentProperties = ( }); } + // Additional actions section items.push({ title: 'Additional actions', isOpen: true, @@ -163,6 +93,7 @@ export const baseComponentProperties = ( ), }); + // Validation section if (validations.length > 0) { items.push({ title: `${i18next.t('widget.common.validation', 'Validation')}`, @@ -182,6 +113,7 @@ export const baseComponentProperties = ( }); } + // Devices section items.push({ title: `${i18next.t('widget.common.devices', 'Devices')}`, isOpen: true, @@ -211,5 +143,24 @@ export const baseComponentProperties = ( ), }); + // Deprecated section + items.push({ + title: 'Deprecated', + isOpen: true, + children: deprecatedProperties?.map((property) => + renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode + ) + ), + }); + return items; }; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js new file mode 100644 index 0000000000..adb487e5a8 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/constants.js @@ -0,0 +1,47 @@ +export const DATATYPE_TO_COMPONENT = { + string: 'TextInput', + number: 'NumberInput', + date: 'DatePickerV2', + boolean: 'Checkbox', + array: 'DropdownV2', +}; + +export const COMPONENT_WITH_OPTIONS = ['DropdownV2', 'MultiselectV2', 'RadioButtonV2']; + +export const INPUT_COMPONENTS_FOR_FORM = [ + 'TextInput', + 'PasswordInput', + 'EmailInput', + 'PhoneInput', + 'CurrencyInput', + 'NumberInput', + 'DropdownV2', + 'MultiselectV2', + 'RadioButtonV2', + 'DatetimePickerV2', + 'Checkbox', + 'ToggleSwitchV2', + 'DatePickerV2', + 'TimePicker', + 'DaterangePicker', + 'TextArea', +]; + +export const JSON_DIFFERENCE = { + isExisting: [], + isNew: [], + isRemoved: [], +}; + +export const FORM_STATUS = { + MANAGE_FIELDS: 'manageFields', + GENERATE_FIELDS: 'generateFields', + REFRESH_FIELDS: 'refreshFields', +}; + +export const COMPONENT_LAYOUT_DETAILS = { + spacing: 40, + defaultWidth: 37, + defaultHeight: 30, + defaultLeft: 3, +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js new file mode 100644 index 0000000000..c561534837 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/columnMappingHandlers.js @@ -0,0 +1,135 @@ +import { isEqual } from 'lodash'; +import { FORM_STATUS, COMPONENT_LAYOUT_DETAILS } from '../constants'; +import { findNextElementTop, cleanupFormFields } from '../utils/utils'; +import { updateFormFieldComponent } from '../utils/fieldOperations'; + +export const createColumnMappingHandler = ({ + component, + isFormGenerated, + currentStatusRef, + formFields, + formFieldsWithComponentDefinition, + getChildComponents, + currentLayout, + performBatchComponentOperations, + saveDataSection, + setOpenModal, +}) => { + return (columns, isSingleUpdate = false) => { + const newColumns = isSingleUpdate ? formFields.filter((field) => field.componentId !== columns[0].componentId) : []; + let operations = { + updated: {}, + added: {}, + deleted: [], + }, + componentsToBeRemoved = []; + + const isFormRegeneration = isFormGenerated && currentStatusRef.current === FORM_STATUS.GENERATE_FIELDS; + + if (!isSingleUpdate) { + if (isFormRegeneration) { + formFields.forEach((field) => { + if (!field.isCustomField) { + componentsToBeRemoved.push(field.componentId); + operations.deleted.push(field.componentId); + } else { + newColumns.push(field); + } + }); + } else if (currentStatusRef.current === FORM_STATUS.GENERATE_FIELDS) { + newColumns.push(...formFields); + } else { + formFields.forEach((field) => { + if (field.isCustomField) { + newColumns.push(field); + } + }); + columns.forEach((column) => { + if (column.isRemoved) { + componentsToBeRemoved.push(column.componentId); + } + }); + } + } + + const childComponents = getChildComponents(component?.id); + // Get the last position of the child components + const nextElementsTop = findNextElementTop(childComponents, currentLayout, componentsToBeRemoved); + // Create form field components from columns + + if (columns && Array.isArray(columns) && columns.length > 0) { + let nextTop = nextElementsTop + COMPONENT_LAYOUT_DETAILS.spacing; + + columns.forEach((column, index) => { + if (column.isRemoved) return operations.deleted.push(column.componentId); + + if (currentStatusRef.current === FORM_STATUS.REFRESH_FIELDS) { + delete column.isRemoved; + delete column.isNew; + delete column.isExisting; + if ( + isEqual( + column, + formFieldsWithComponentDefinition.find((field) => field.componentId === column.componentId) + ) + ) { + return newColumns.push(column); + } + } + + if ( + currentStatusRef.current === FORM_STATUS.MANAGE_FIELDS && + isEqual( + column, + formFieldsWithComponentDefinition.find((field) => field.componentId === column.componentId) + ) + ) { + return newColumns.push(column); + } + + const { + added = {}, + updated = {}, + deleted = false, + } = updateFormFieldComponent(column, {}, component.id, nextTop); + + if (Object.keys(updated).length !== 0) { + operations.updated[column.componentId] = updated; + newColumns.push(column); + } + if (Object.keys(added).length !== 0) { + operations.added[added.id] = added; + if (added.component.component === 'Checkbox') { + nextTop = nextTop + added.layouts['desktop'].height + 10; + } else { + nextTop = nextTop + added.layouts['desktop'].height + COMPONENT_LAYOUT_DETAILS.spacing; + } + + // Create simplified column structure with only the required fields + const simplifiedColumn = { + componentId: added.id, + isCustomField: column.isCustomField ?? false, + dataType: column.dataType, + key: column.key || column.name, + }; + + columns[index] = simplifiedColumn; // Replace with simplified structure + newColumns.push(simplifiedColumn); + } + if (deleted) { + operations.deleted.push(column.componentId); + } + }); + + if ( + Object.keys(operations.updated).length > 0 || + Object.keys(operations.added).length > 0 || + operations.deleted.length > 0 + ) { + performBatchComponentOperations(operations); + saveDataSection(cleanupFormFields(newColumns)); + } + setOpenModal(false); + } + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js new file mode 100644 index 0000000000..ce043f77a1 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/index.js @@ -0,0 +1,3 @@ +export { createParamUpdatedInterceptor } from './parameterHandlers'; +export { createColumnMappingHandler } from './columnMappingHandlers'; +export { createJSONDataBlurHandler } from './jsonDataHandlers'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js new file mode 100644 index 0000000000..7c08168da4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/jsonDataHandlers.js @@ -0,0 +1,79 @@ +import { isEqual, merge } from 'lodash'; +import { FORM_STATUS } from '../constants'; +import { + parseDataAndBuildFields, + analyzeJsonDifferences, + mergeFormFieldsWithNewData, + mergeFieldsWithComponentDefinition, +} from '../utils/utils'; + +export const createJSONDataBlurHandler = ({ + component, + currentStatusRef, + resolveReferences, + getFormDataSectionData, + savedSourceValue, + source, + formFieldsWithComponentDefinition, + existingResolvedJsonData, + getComponentDefinition, + performColumnMapping, + saveDataSection, + codeEditorView, +}) => { + return async (newJSONValue = null) => { + if (codeEditorView.dom && codeEditorView.dom.parentNode) { + codeEditorView.dom.parentNode.classList.remove('focused'); + } + + const existingData = getFormDataSectionData(component?.id); + const isFormGenerated = existingData && existingData.generateFormFrom && existingData.JSONData; + + // Resolve both values to compare actual data, not just string comparison + const resolvedNewJSONValue = resolveReferences('canvas', newJSONValue); + const existingResolvedValue = existingData?.JSONData?.value + ? resolveReferences('canvas', existingData.JSONData.value) + : null; + + // Use deep comparison to check if there's actual content change + const hasDataChanged = !isEqual(resolvedNewJSONValue, existingResolvedValue); + + // Only proceed if there's actual data and changes + if (!resolvedNewJSONValue || !newJSONValue) { + return; + } + + if (!isFormGenerated) { + currentStatusRef.current = FORM_STATUS.GENERATE_FIELDS; + const columns = parseDataAndBuildFields(resolvedNewJSONValue); + + if (columns && columns.length > 0) { + performColumnMapping(columns); + } + return; + } + + if (hasDataChanged) { + const sourceChanged = !isEqual(savedSourceValue.current, source?.value); + currentStatusRef.current = sourceChanged ? FORM_STATUS.GENERATE_FIELDS : FORM_STATUS.REFRESH_FIELDS; + const jsonDifferences = analyzeJsonDifferences( + resolvedNewJSONValue, + sourceChanged ? null : existingResolvedJsonData + ); + + const mergedJsonData = merge({}, sourceChanged ? {} : existingResolvedJsonData, resolvedNewJSONValue); + const parsedFields = parseDataAndBuildFields(mergedJsonData, jsonDifferences); + const mergedFields = mergeFormFieldsWithNewData(formFieldsWithComponentDefinition, parsedFields); + const enhancedFieldsWithComponentDefinition = mergeFieldsWithComponentDefinition( + mergedFields, + getComponentDefinition + ); + + if (enhancedFieldsWithComponentDefinition && enhancedFieldsWithComponentDefinition.length > 0) { + performColumnMapping(enhancedFieldsWithComponentDefinition); + } + } else if (savedSourceValue.current === 'jsonSchema') { + return saveDataSection(formFieldsWithComponentDefinition); + } + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js new file mode 100644 index 0000000000..a120175f67 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/handlers/parameterHandlers.js @@ -0,0 +1,105 @@ +import { extractAndReplaceReferencesFromString } from '@/AppBuilder/_stores/ast'; +import { findFirstKeyValuePairWithPath } from '../utils/utils'; + +export const createParamUpdatedInterceptor = ({ + component, + paramUpdated, + source, + setSource, + setJSONData, + setOpenModal, + shouldFocusJSONDataEditor, + shouldInvokeBlurEvent, + savedSourceValue, + componentNameIdMapping, + queryNameIdMapping, + getFormDataSectionData, + getExposedValueOfQuery, + runQuery, + resolveReferences, + setLoading, +}) => { + return async (param, attr, value, paramType, ...restArgs) => { + // Handle generateFormFrom parameter + if (param?.name === 'generateFormFrom') { + shouldFocusJSONDataEditor.current = false; + if (attr === 'value') { + const res = extractAndReplaceReferencesFromString(value, componentNameIdMapping, queryNameIdMapping); + let { valueWithId: selectedQuery, allRefs, valueWithBrackets } = res; + const { generateFormFrom, JSONData } = getFormDataSectionData(component?.id); + + if (value === generateFormFrom?.value) { + setSource((prev) => ({ ...prev, value })); + return setJSONData({ value: JSONData.value }); + } + + if (value === 'jsonSchema') { + setSource({ value: 'jsonSchema' }); + savedSourceValue.current = 'jsonSchema'; + return paramUpdated(param, attr, value, paramType, ...restArgs); + } else if (value === 'rawJson') { + shouldFocusJSONDataEditor.current = true; + setJSONData({ + value: + "{{{ 'name': 'John Doe', 'age': 35, 'isActive': true, 'dob': '01-01-1990', 'hobbies': ['reading', 'gaming', 'cycling'], 'address': { 'street': '123 Main Street', 'city': 'New York' } }}}", + }); + return setSource((prev) => ({ ...prev, value })); + } else if (value !== 'rawJson' && value !== 'jsonSchema') { + // Set the source value to the selected query until the query is run + setSource((prev) => ({ ...prev, value: selectedQuery })); + setLoading(true); + + const queryRefs = allRefs + .filter((ref) => ref.entityType === 'queries') + .filter((ref, index, self) => index === self.findIndex((r) => r.entityNameOrId === ref.entityNameOrId)); + + setOpenModal(true); + await Promise.all( + queryRefs.map(async (ref) => { + const queryId = ref.entityNameOrId; + const resolvedValueofQuery = getExposedValueOfQuery(queryId, 'canvas'); + + const hasMetadata = + resolvedValueofQuery && typeof resolvedValueofQuery === 'object' && 'metadata' in resolvedValueofQuery; + if (!hasMetadata && queryId && runQuery) { + await runQuery(queryId, '', false, 'edit'); + } + }) + ); + + let resolvedValue; + + resolvedValue = resolveReferences('canvas', valueWithBrackets); + setLoading(false); + + if (!source?.fxActive) { + const transformedData = findFirstKeyValuePairWithPath(resolvedValue, selectedQuery); + setJSONData({ value: transformedData.value }); + return setSource((prev) => ({ ...prev, value: transformedData.path })); + } + + setJSONData({ value: resolvedValue }); + setOpenModal(true); + } + setSource((prev) => ({ ...prev, value: selectedQuery })); + } else if (attr === 'fxActive') { + setSource((prev) => ({ ...prev, fxActive: value })); + } + return; + } + + // Handle JSONData parameter + if (param.name === 'JSONData') { + if (attr === 'value') { + if (source.value === 'rawJson') { + shouldInvokeBlurEvent.current = true; + } + setJSONData({ value }); + } + return; + } + + // Default parameter update + paramUpdated(param, attr, value, paramType, ...restArgs); + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js new file mode 100644 index 0000000000..ccb7dede95 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/index.js @@ -0,0 +1 @@ +export { Form as default } from './Form'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss new file mode 100644 index 0000000000..3f3f9ecef0 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/styles.scss @@ -0,0 +1,258 @@ +.form-generate-form-btn { + button { + &:disabled { + border: 1px solid var(--border-weak, #E4E7EB) !important; + box-shadow: none; + } + } +} + +.column-mapping-modal-header { + background-color: var(--primary-white) !important; + border-bottom: 1px solid var(--border-medium, rgba(106, 114, 124, 0.26)); + +} + +.column-mapping-modal-body { + background: var(--page-page-default, #F6F8FA); + box-shadow: 0px 0px 1px 0px var(--dropshadow-100700-layer-1, rgba(48, 50, 51, 0.05)), 0px 8px 16px 0px var(--dropshadow-100400-layer-2, rgba(48, 50, 51, 0.10)); + + .column-mapping-modal-body-content { + background-color: var(--primary-white) !important; + border-radius: 8px; + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0px; + } + + .column-mapping-modal-title { + padding-left: 10px; + padding-top: 10px; + color: var(--text-default, #1B1F24); + + &.new { + color: var(--text-success, #1E823B); + } + + &.removed { + color: var(--text-danger, #D92D2A); + } + } + + .header-row { + height: 36px; + + .header-column { + height: 16px; + + span { + vertical-align: top; + } + + button[role="checkbox"] { + margin-top: 0px; + } + + .editable-icon svg { + vertical-align: initial; + } + } + + } + + .name-column { + width: 230px; + margin-right: 6px; + } + + .arrow-column { + width: 40px + } + + .mapped-column { + width: 160px; + padding: 0px 10px; + } + + .type-column { + width: 76px; + padding: 0px 10px; + + &.rows { + width: 160px; + } + } + + .mandatory-column { + width: 84px; + margin-left: 8px; + + &.rows { + width: 16px; + } + } + + + .column-mapping-row { + height: 40px; + color: var(--text-default, #1B1F24); + border-bottom: 1px solid var(--border-weak, #E4E7EB); + + span.base-regular { + color: var(--text-default, #1B1F24); + } + + &:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + .data-type { + color: var(--text-placeholder, #6A727C); + font-family: monospace; + font-size: 11px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + + .hide-border { + + button[role="combobox"], + input[type="text"] { + border-color: transparent; + border-radius: 6px; + + &:hover { + border-color: var(--border-strong, #ACB2B9); + } + } + } + + .no-mapped-column { + border-radius: 9px; + background-color: var(--interactive-default); + height: 18px; + padding: 0px 6px; + color: var(--text-placeholder, #6A727C); + } + } + + } +} + +.field-item { + background-color: var(--interactive-default); + border-radius: 6px; + padding: 7px 8px; + height: 32px; + + &:hover { + background-color: var(--interactive-hover) + } + + &.selected { + background-color: var(--interactive-selected) !important; + } + + .field-name { + overflow: hidden; + color: var(--text-default, #1B1F24); + text-overflow: ellipsis; + } + + .more-btn { + width: 22px; + height: 22px; + padding: 4px; + border-radius: 4px; + border: 1px solid var(--border-weak, #E4E7EB); + background: var(--button-secondary, #FFF); + /* Elevations/100 */ + box-shadow: 0px 0px 1px 0px var(--dropshadow-100700-layer-1, rgba(48, 50, 51, 0.05)), 0px 1px 1px 0px var(--dropshadow-100400-layer-2, rgba(48, 50, 51, 0.10)); + } +} + +.field-icon { + background-color: inherit; + color: #6b7280; +} + +.field-popover { + animation: popoverFade 0.2s ease-in-out; +} + +@keyframes popoverFade { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +#menu-popover { + .popover-body { + min-width: 200px; + } + + button { + width: 100%; + text-align: left; + justify-content: flex-start !important; + border-radius: 6px; + padding: 6px 8px; + color: var(--text-default, #1B1F24); + + &:hover { + background-color: var(--interactive-default); + } + } +} + +.form-fields-column-popover { + border-radius: 8px; + width: 303px; + + .form-field-popover-header { + border-bottom: 1px solid var(--border-weak, #E4E7EB); + } + + .form-field-popover-body { + + button[role="combobox"] { + border-radius: 6px; + } + + label { + margin-bottom: 2px; + font-family: "IBM Plex Sans"; + } + } + +} + +.refresh-data-section { + border-radius: 6px; + background: var(--background-surface-layer-02); + margin-top: 12px; + + .neutral-light-color { + color: var(--neutral-light-n-900, #091E42); + } + + .refresh-data-button { + width: 147px; + margin: 0px 24px; + } +} + +.custom-schema-fields-section { + background: var(--background-warning-weak, #FAEFE7); + border-radius: 6px; + margin-top: 12px; +} \ No newline at end of file diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js new file mode 100644 index 0000000000..2f009b4784 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/componentMetaUtils.js @@ -0,0 +1,66 @@ +import { deepClone } from '@/_helpers/utilities/utils.helpers'; + +export const processComponentMeta = (componentMeta, component, allComponents, resolvedCustomSchema) => { + const tempComponentMeta = deepClone(componentMeta); + + let properties = []; + let additionalActions = []; + let dataProperties = []; + let deprecatedProperties = []; + + const events = Object.keys(componentMeta.events); + const validations = Object.keys(componentMeta.validation || {}); + + // Categorize properties + for (const [key] of Object.entries(componentMeta?.properties)) { + if (componentMeta?.properties[key]?.section === 'additionalActions') { + additionalActions.push(key); + } else if (componentMeta?.properties[key]?.section === 'data') { + dataProperties.push(key); + } else if (componentMeta?.properties[key]?.section === 'deprecated') { + deprecatedProperties.push(key); + } else { + // Skip the fields property as it is handled separately + if (key === 'fields') continue; + properties.push(key); + } + } + + // Process button to submit options + const { id } = component; + const newOptions = [{ name: 'None', value: 'none' }]; + + Object.entries(allComponents).forEach(([componentId, _component]) => { + const validParent = + _component.component.parent === id || + _component.component.parent === `${id}-footer` || + _component.component.parent === `${id}-header`; + if (validParent && _component?.component?.component === 'Button') { + newOptions.push({ name: _component.component.name, value: componentId }); + } + }); + + tempComponentMeta.properties.buttonToSubmit.options = newOptions; + + // Hide header footer if custom schema is turned on + if (resolvedCustomSchema) { + component.component.properties.showHeader = { + ...component.component.properties.headerHeight, + isHidden: true, + }; + component.component.properties.showFooter = { + ...component.component.properties.headerHeight, + isHidden: true, + }; + } + + return { + tempComponentMeta, + properties, + additionalActions, + dataProperties, + deprecatedProperties, + events, + validations, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js new file mode 100644 index 0000000000..2cfc01ca8b --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/fieldOperations.js @@ -0,0 +1,292 @@ +import { merge, set } from 'lodash'; +import { deepClone } from '@/_helpers/utilities/utils.helpers'; +import { v4 as uuidv4 } from 'uuid'; +import { componentTypes } from '@/AppBuilder/WidgetManager'; +import useStore from '@/AppBuilder/_stores/store'; +// eslint-disable-next-line import/no-unresolved +import { diff } from 'deep-object-diff'; +import { ensureHandlebars, buildOptions } from './utils'; +import { COMPONENT_LAYOUT_DETAILS, COMPONENT_WITH_OPTIONS } from '../constants'; + +export const createNewComponentFromMeta = (column, parentId, nextTop) => { + const currentLayout = useStore.getState().currentLayout; + const componentType = column.componentType || 'TextInput'; + const fieldId = uuidv4(); + + const componentMeta = componentTypes.find((comp) => comp.component === componentType); + + if (!componentMeta) { + console.error(`Component type ${componentType} not found in componentTypes`); + return; + } + + const defaultHeight = componentMeta.defaultSize?.height || COMPONENT_LAYOUT_DETAILS.defaultHeight; + + const componentData = deepClone(componentMeta); + const componentName = useStore.getState().generateUniqueComponentNameFromBaseName(column.name); + + const formField = { + id: fieldId, + name: componentName, + component: { + ...componentData, + type: componentType, + name: componentName, + parent: parentId, + definition: merge({}, componentData.definition, { + properties: { + label: { + value: column.label, + }, + }, + styles: { + alignment: { value: 'top' }, + }, + validation: { + mandatory: column.mandatory, + }, + others: { + showOnDesktop: { + value: currentLayout === 'desktop' ? '{{true}}' : '{{false}}', + }, + showOnMobile: { + value: currentLayout === 'mobile' ? '{{true}}' : '{{false}}', + }, + }, + }), + }, + layouts: { + desktop: { + top: nextTop, + left: COMPONENT_LAYOUT_DETAILS.defaultLeft, + width: COMPONENT_LAYOUT_DETAILS.defaultWidth, + height: defaultHeight, + }, + mobile: { + top: nextTop, + left: COMPONENT_LAYOUT_DETAILS.defaultLeft, + width: COMPONENT_LAYOUT_DETAILS.defaultWidth, + height: defaultHeight, + }, + }, + }; + + setValuesBasedOnType(column, componentType, formField, false); + + return { + deleted: false, + added: formField, + updated: {}, + }; +}; + +/** + * Updates an existing form field component with new values + * @param {string} componentId - ID of the component to update + * @param {Object} updatedField - New field values to apply + * @param {Object} currentField - Current field data + * @returns {Object} Updated component definition + */ +export const updateFormFieldComponent = (updatedField, currentField, parentId, nextTop = 0) => { + const componentId = updatedField?.componentId; + + if (!componentId) { + // componentId is not available, create a new component + return createNewComponentFromMeta(updatedField, parentId, nextTop); + } + + // Get the current component from the store + const componentToUpdate = useStore.getState().getComponentDefinition(componentId); + + if (!componentToUpdate) { + console.error(`Component with ID ${componentId} not found`); + return null; + } + + if (updatedField.componentType !== componentToUpdate.component.component) { + return handleComponentTypeChange(componentToUpdate, updatedField); + } + + // Create a deep clone of the component to avoid reference issues + const updatedComponent = deepClone(componentToUpdate); + + // Update label if changed + if (updatedField.label !== currentField.label) { + set(updatedComponent.component.definition.properties, 'label.value', updatedField.label); + } + + // Update mandatory status + if (updatedField.mandatory !== currentField.mandatory) { + set(updatedComponent.component.definition.validation, 'mandatory', updatedField.mandatory); + } + + // Update visibility status + if (updatedField.visibility !== currentField.visibility) { + set(updatedComponent.component.definition.properties, 'visibility', updatedField.visibility); + } + + // Update component type specific properties + const componentType = updatedField.componentType || componentToUpdate.component.component; + + setValuesBasedOnType(updatedField, componentType, updatedComponent, false); + + return { updated: diff(componentToUpdate, updatedComponent) }; +}; + +const handleComponentTypeChange = (componentToUpdate, updatedField) => { + const newComponentId = uuidv4(); + + const addOptions = + COMPONENT_WITH_OPTIONS.includes(updatedField.componentType) && + COMPONENT_WITH_OPTIONS.includes(componentToUpdate.component.component); + + const currentLayout = useStore.getState().currentLayout; + const nonActiveLayout = currentLayout === 'desktop' ? 'mobile' : 'desktop'; + + const componentMeta = componentTypes.find((comp) => comp.component === updatedField.componentType); + + if (!componentMeta) { + console.error(`Component type ${updatedField.componentType} not found in componentTypes`); + return null; + } + + const existingLayouts = componentToUpdate.layouts || {}; + + const componentName = useStore + .getState() + .generateUniqueComponentNameFromBaseName(updatedField.name || componentToUpdate.component.name); + + const componentData = deepClone(componentMeta); + + const newComponent = { + id: newComponentId, + name: componentName, + component: { + ...componentData, + type: updatedField.componentType, + name: componentName, + parent: componentToUpdate.component.parent, + definition: merge({}, componentData.definition, { + properties: { + label: { + value: updatedField.label || componentToUpdate.component.definition.properties.label?.value, + }, + ...(addOptions && { options: componentToUpdate.component.definition.properties.options }), + }, + styles: { + alignment: { value: 'top' }, + }, + validation: { + mandatory: updatedField.mandatory || componentToUpdate.component.definition.validation.mandatory, + }, + others: { + showOnDesktop: componentToUpdate.component.definition.others?.showOnDesktop || { value: '{{true}}' }, + showOnMobile: componentToUpdate.component.definition.others?.showOnMobile || { value: '{{false}}' }, + }, + }), + }, + layouts: { + [currentLayout]: existingLayouts[currentLayout] || { top: 0, left: 3, width: 37, height: 30 }, + [nonActiveLayout]: existingLayouts[nonActiveLayout] || { top: 0, left: 3, width: 37, height: 30 }, + }, + }; + + setValuesBasedOnType(updatedField, updatedField.componentType, newComponent, true); + + // Return an object that indicates to: + // 1. Delete the old component + // 2. Add the new component + return { + deleted: true, + added: newComponent, + updated: {}, + }; +}; + +const setValuesBasedOnType = (column, componentType, formField, isTypeChange = false) => { + if (column.value !== undefined && column.value !== null) { + if (componentType === 'TextInput' || componentType === 'PasswordInput' || componentType === 'TextArea') { + set(formField.component.definition.properties, 'value.value', column.value); + } + if (componentType === 'NumberInput') { + set(formField.component.definition.properties, 'value.value', ensureHandlebars(column.value)); + } else if (componentType === 'Checkbox' || componentType === 'DatePickerV2' || componentType === 'ToggleSwitchV2') { + set(formField.component.definition.properties, 'defaultValue.value', column.value); + } else if ( + componentType === 'DropdownV2' || + componentType === 'MultiselectV2' || + componentType === 'RadioButtonV2' + ) { + if (!isTypeChange) { + set(formField.component.definition.properties, 'options.value', buildOptions(column.value)); + } else if (Array.isArray(formField.component.definition.properties?.options)) { + set( + formField.component.definition.properties, + 'options.value', + buildOptions(formField.component.definition.properties.options) + ); + } + } + } + + if (isTypeChange && componentType === 'TextArea') { + set(formField, 'layouts.desktop.height', 50); + set(formField, 'layouts.mobile.height', 50); + } + + if ( + column.placeholder && + componentType !== 'Checkbox' && + componentType !== 'DatePickerV2' && + componentType !== 'ToggleSwitchV2' && + componentType !== 'DaterangePicker' + ) { + set(formField.component.definition.properties, 'placeholder.value', column.placeholder); + } +}; + +/** + * Retrieves field data from a component definition in the store + * @param {string} componentId - Component ID to fetch definition for + * @param {Function} getComponentDefinition - Function to get component definition + * @returns {Object} Field data with merged component definition values + */ +export const getFieldDataFromComponent = (componentId, getComponentDefinition) => { + if (!componentId) { + return null; + } + + const component = getComponentDefinition(componentId); + if (!component) return null; + + const componentType = component.component.component; + const definition = component.component.definition; + + // Get values from component definition + const label = definition.properties?.label?.value || ''; + const name = component.component.name; + + // Different components store values in different properties + let value; + if (componentType === 'Checkbox' || componentType === 'DatePickerV2') { + value = definition.properties?.defaultValue?.value; + } else { + value = definition.properties?.value?.value; + } + + const mandatory = definition.validation?.mandatory; + const visibility = definition.properties?.visibility; + const selected = true; + const placeholder = definition.properties?.placeholder?.value || ''; + + return { + label, + name, + value, + mandatory, + visibility, + selected, + placeholder, + componentType, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js new file mode 100644 index 0000000000..d5bb362dda --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Form/utils/utils.js @@ -0,0 +1,333 @@ +import React from 'react'; +import WidgetIcon from '@/../assets/images/icons/widgets'; +import { DATATYPE_TO_COMPONENT, JSON_DIFFERENCE, INPUT_COMPONENTS_FOR_FORM } from '../constants'; +import { startCase, omit, uniqBy } from 'lodash'; +import { getFieldDataFromComponent } from './fieldOperations'; +import { componentTypeDefinitionMap } from '@/AppBuilder/WidgetManager'; + +export const buildOptions = (options = []) => { + if (Array.isArray(options)) + return options.map((option, index) => ({ + label: option, + value: index, + disable: { value: false }, + visible: { value: true }, + default: { value: false }, + })); +}; + +export const ensureHandlebars = (value) => { + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + return value; // Already has handlebars + } + return `{{${value}}}`; +}; + +// Helper function to check if a value is considered "true" +export const isTrueValue = (value) => { + if (value === true) return true; + if (typeof value === 'string') { + const trimmedValue = value.trim().toLowerCase(); + // Check for "{{true}}" format or just "true" + return trimmedValue === '{{true}}' || trimmedValue === 'true'; + } + return false; +}; + +export const isPropertyFxControlled = (property) => { + return property && typeof property === 'object' && property.fxActive === true; +}; + +export const isValidJSONObject = (jsonString) => { + try { + const parsed = JSON.parse(jsonString); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed); + } catch (e) { + return false; + } +}; + +export const getDataType = (value) => { + if (Array.isArray(value)) return 'array'; + if (typeof value === 'string') { + const date = new Date(value); + if (!isNaN(date.getTime())) return 'date'; + return 'string'; + } + if (typeof value === 'object' && value !== null) return 'object'; + return typeof value; +}; + +export const buildFieldObject = (key, value, label, jsonDifferences) => { + const dataType = getDataType(value); + + return { + key, + name: key, + label: startCase(label) || startCase(key), + value: dataType === 'number' || dataType === 'boolean' ? ensureHandlebars(value) : value, + dataType, + componentType: DATATYPE_TO_COMPONENT[dataType] || 'TextInput', + mandatory: { value: false }, + selected: false, + isCustomField: false, + isNew: jsonDifferences.isNew.includes(key), + isRemoved: jsonDifferences.isRemoved.includes(key), + isExisting: jsonDifferences.isExisting.includes(key), + }; +}; + +export const parseDataAndBuildFields = (data, jsonDifferences = JSON_DIFFERENCE) => { + const obj = data || {}; + const result = []; + + Object.entries(obj).forEach(([key, value]) => { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const nestedKeys = Object.keys(value); + if (nestedKeys.length === 0) { + return; + } + + nestedKeys.forEach((nestedKey) => { + const nestedValue = value[nestedKey]; + if ( + typeof nestedValue === 'object' && + nestedValue !== null && + !Array.isArray(nestedValue) && + Object.keys(nestedValue).length === 0 + ) { + return; + } + + result.push(buildFieldObject(`${key}.${nestedKey}`, nestedValue, nestedKey, jsonDifferences)); + }); + } else { + result.push(buildFieldObject(key, value, key, jsonDifferences)); + } + }); + + return result; +}; + +export const findNextElementTop = (childComponents, currentLayout = 'desktop', componentsToBeIgnored = []) => { + const defaultTop = 0; + + if (!childComponents || typeof childComponents !== 'object' || Object.keys(childComponents).length === 0) { + return defaultTop; + } + + try { + let highestTop = -1; + let lastComponent = null; + + Object.entries(childComponents).forEach(([componentId, component]) => { + if (componentsToBeIgnored.includes(componentId)) { + return; + } + + const currentTop = component?.component?.layouts?.[currentLayout]?.top || 0; + + if (currentTop > highestTop) { + highestTop = currentTop; + lastComponent = component; + } + }); + + if ( + lastComponent && + lastComponent.component && + lastComponent.component.layouts && + lastComponent.component.layouts[currentLayout] + ) { + const { top = 0, height = 0 } = lastComponent.component.layouts[currentLayout]; + + return top + height; + } + + return defaultTop; + } catch (error) { + console.error('Error finding last element position:', error); + return defaultTop; + } +}; + +export const getComponentIcon = (componentType, darkMode) => { + if (!componentType) return null; + + const component = componentTypeDefinitionMap[componentType]; + + const iconName = component.name.toLowerCase(); + return ; +}; + +export const getInputTypeOptions = (darkMode) => { + const constructOptions = (component) => { + return { + label: component.displayName, + value: component.component, + leadingIcon: ( + + ), + }; + }; + + return INPUT_COMPONENTS_FOR_FORM.reduce((options, component) => { + options[component] = constructOptions(componentTypeDefinitionMap[component]); + return options; + }, {}); +}; + +export const constructFeildForSave = (field) => { + const { key, value, dataType, componentType, mandatory, selected, isCustomField } = field; + + return { + key, + value: dataType === 'number' || dataType === 'boolean' ? ensureHandlebars(value) : value, + dataType, + componentType, + mandatory: mandatory?.value || false, + selected: selected?.value || false, + isCustomField: isCustomField || false, + }; +}; + +const extractKeys = (json, parentKey = '') => { + if (!json || typeof json !== 'object') return []; + + return Object.keys(json).reduce((keys, key) => { + const currentKey = parentKey ? `${parentKey}.${key}` : key; + const value = json[key]; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + return [...keys, currentKey, ...extractKeys(value, currentKey)]; + } + + return [...keys, currentKey]; + }, []); +}; + +export const analyzeJsonDifferences = (newJson, existingJson) => { + if (!newJson) return JSON_DIFFERENCE; + + const newKeys = extractKeys(newJson); + const existingKeys = extractKeys(existingJson); + + return { + isExisting: newKeys.filter((key) => existingKeys.includes(key)), + isNew: newKeys.filter((key) => !existingKeys.includes(key)), + isRemoved: existingKeys.filter((key) => !newKeys.includes(key)), + }; +}; + +export const mergeFieldsWithComponentDefinition = (fields, getComponentDefinition) => { + return fields + .map((field) => { + if (field.componentId) { + const componentData = getFieldDataFromComponent(field.componentId, getComponentDefinition); + + if (!componentData) { + return null; + } + + return { + ...field, + label: componentData?.label || field.label || '', + name: componentData?.name || field.name || '', + value: componentData?.value || field.value || '', + mandatory: componentData?.mandatory || field.mandatory || false, + visibility: componentData?.visibility || field.visibility || false, + selected: componentData?.selected || field.selected || false, + placeholder: componentData?.placeholder || field.placeholder || '', + componentType: componentData?.componentType || field.componentType || 'TextInput', + }; + } + return field; + }) + .filter((field) => field !== null); +}; + +export const mergeFormFieldsWithNewData = (existingFields, newFields) => { + if (!existingFields || existingFields.length === 0) return newFields; + + const existingFieldsMap = {}; + existingFields.forEach((field) => { + if (field.key) { + existingFieldsMap[field.key] = field; + } + }); + + return newFields.map((newField) => { + if (newField.isNew || !existingFieldsMap[newField.key]) { + return newField; + } + return { + ...newField, + ...omit(existingFieldsMap[newField.key], ['isNew']), + }; + }); +}; + +export const cleanupFormFields = (fields) => { + return uniqBy( + fields.filter((field) => !!field.componentId), + 'componentId' + ).map((field) => ({ + componentId: field.componentId, + isCustomField: field.isCustomField, + dataType: field.dataType, + key: field.key, + })); +}; + +export const findFirstKeyValuePairWithPath = (data, basePath = '') => { + let current = data; + let pathSegments = []; + + if (data === null || data === undefined || data?.length === 0) { + return { + value: data, + path: basePath, + }; + } + + while (Array.isArray(current) && current.length > 0) { + pathSegments.push('[0]'); + current = current[0]; + } + + if (current && typeof current === 'object' && !Array.isArray(current)) { + // Inject path segments before the closing "}}" + const insertAt = basePath.lastIndexOf('}}'); + const fullPath = + insertAt !== -1 + ? basePath.slice(0, insertAt) + pathSegments.join('') + basePath.slice(insertAt) + : basePath + pathSegments.join(''); + + return { + value: current, + path: fullPath, + }; + } + + return { + value: null, + path: null, + }; +}; + +export const mergeArrays = (arr1, arr2) => { + const map = new Map(); + + // Add all from arr1 + arr1.forEach((item) => map.set(item.isCustomField ? item.componentId : item.key, item)); + + // Overwrite/add from arr2 + arr2.forEach((item) => map.set(item.isCustomField ? item.componentId : item.key, item)); + + return Array.from(map.values()); +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx index ed1814eb0e..b44bc860df 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Modal.jsx @@ -43,7 +43,9 @@ export const Modal = ({ componentMeta, darkMode, ...restProps }) => { return accordionItems; }; - const properties = Object.keys(componentMeta.properties); + const properties = Object.keys(componentMeta.properties || {}).filter( + (key) => componentMeta.properties[key].section !== 'additionalActions' + ); const events = Object.keys(componentMeta.events); const validations = Object.keys(componentMeta.validation || {}); @@ -64,7 +66,8 @@ export const Modal = ({ componentMeta, darkMode, ...restProps }) => { apps, allComponents, validations, - darkMode + darkMode, + undefined ); accordionItems.splice(1, 0, ...conditionalAccordionItems(component)); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx new file mode 100644 index 0000000000..35fa70ae66 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/TabComponent.jsx @@ -0,0 +1,527 @@ +import React, { useState, useEffect } from 'react'; +import Accordion from '@/_ui/Accordion'; +import { EventManager } from '../EventManager'; +import { renderElement } from '../Utils'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; +import List from '@/ToolJetUI/List/List'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { resolveReferences } from '@/_helpers/utils'; +import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton'; +import ListGroup from 'react-bootstrap/ListGroup'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import SortableList from '@/_components/SortableList'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; + +export function TabsLayout({ componentMeta, darkMode, ...restProps }) { + const { + layoutPropertyChanged, + component, + dataQueries, + paramUpdated, + currentState, + eventsChanged, + apps, + allComponents, + pages, + } = restProps; + + const isDynamicEnabled = resolveReferences( + component?.component?.definition?.properties?.useDynamicOptions?.value, + currentState + ); + + const [tabItems, setTabItems] = useState([]); + const [activeColumnPopoverIndex, setActiveColumnPopoverIndex] = useState(null); + const [hoveredTabItemIndex, setHoveredTabItemIndex] = useState(null); + let properties = []; + let additionalActions = []; + + for (const [key] of Object.entries(componentMeta?.properties)) { + if (componentMeta?.properties[key]?.section === 'additionalActions') { + additionalActions.push(key); + } else { + properties.push(key); + } + } + + const constructTabItems = () => { + const tabItemsValue = component?.component?.definition?.properties?.tabItems?.value; + let tabItems = []; + + if (typeof tabItemsValue === 'string') { + tabItems = resolveReferences(tabItemsValue, currentState); + } else { + tabItems = tabItemsValue?.map((tabItem) => tabItem); + } + return tabItems?.map((tabItem) => { + const newTabItem = { ...tabItem }; + + Object.keys(tabItem)?.forEach((key) => { + if (typeof tabItem[key]?.value === 'boolean') { + newTabItem[key]['value'] = `{{${tabItem[key]?.value}}}`; + } + }); + + return newTabItem; + }); + }; + + const handleAddTabItem = () => { + const generateNewTabItem = () => { + let found = false; + let title = ''; + let currentNumber = tabItems.length; + let id = `t${currentNumber}`; + while (!found) { + title = `Tab ${currentNumber}`; + if (tabItems.find((tabItem) => tabItem.title === title) === undefined) { + found = true; + } + currentNumber += 1; + } + return { + id: id, + title, + visible: { value: '{{true}}' }, + disable: { value: '{{false}}' }, + iconVisibility: { value: '{{false}}' }, + icon: { value: 'IconHome2' }, + }; + }; + + let newTabItem = generateNewTabItem(); + const updatedTabItems = [...tabItems, newTabItem]; + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const updateAllTabItemsParams = (tabItems) => { + paramUpdated({ name: 'tabItems' }, 'value', tabItems, 'properties', false); + }; + + const getItemStyle = (isDragging, draggableStyle) => ({ + userSelect: 'none', + ...draggableStyle, + }); + + const handleDeleteTabItem = (index) => { + const updatedTabItems = tabItems.filter((tabItem, i) => i !== index); + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const reorderTabItems = (startIndex, endIndex) => { + const result = Array.from(tabItems); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + setTabItems(result); + updateAllTabItemsParams(result); + }; + + const onDragEnd = ({ source, destination }) => { + if (!destination || source.index === destination.index) { + return; + } + reorderTabItems(source.index, destination.index); + }; + + const handleValueChange = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + return { + ...tabItem, + [property]: value, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const onChangeVisibility = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + let newVisibilityValue = resolveReferences(tabItem[property]); + newVisibilityValue = typeof newVisibilityValue === 'boolean' ? newVisibilityValue : newVisibilityValue['value']; + return { + ...tabItem, + [property]: !newVisibilityValue, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const onChangeIcon = (item, value, property, index) => { + const updatedTabItems = tabItems.map((tabItem) => { + if (tabItem.id === item.id) { + return { + ...tabItem, + [property]: value, + iconVisibility: { value: true }, + }; + } + return tabItem; + }); + + setTabItems(updatedTabItems); + updateAllTabItemsParams(updatedTabItems); + }; + + const _renderOverlay = (item, index) => { + return ( + + +
+ + handleValueChange(item, value, 'title', index)} + /> +
+ +
+ + handleValueChange(item, value, 'id', index)} + /> +
+ +
+ { + onChangeIcon(item, { value }, 'icon', index); + }} + onVisibilityChange={(value) => onChangeVisibility(item, { value: true }, 'iconVisibility', index)} + fieldMeta={{ type: 'icon', displayName: 'Icon' }} + paramType={'icon'} + /> +
+ +
+ { + handleValueChange(item, { value }, 'fieldBackgroundColor', index); + }} + fieldMeta={{ type: 'color', displayName: 'Background' }} + paramType={'color'} + /> +
+ +
+ { + handleValueChange(item, { value }, 'loading', index); + }} + fieldMeta={{ type: 'toggle', displayName: 'Loading' }} + paramType={'toggle'} + /> +
+ +
+ handleValueChange(item, { value }, 'visible', index)} + paramName={'visible'} + onFxPress={(active) => handleOnFxPress(active, index, 'visible')} + fxActive={item?.visible?.fxActive} + fieldMeta={{ + type: 'toggle', + displayName: 'Visible', + }} + paramType={'toggle'} + /> +
+
+ handleValueChange(item, { value }, 'disable', index)} + onFxPress={(active) => handleOnFxPress(active, index, 'disable')} + fxActive={item?.disable?.fxActive} + fieldMeta={{ + type: 'toggle', + displayName: 'Disable', + }} + paramType={'toggle'} + /> +
+
+
+ ); + }; + + useEffect(() => { + setTabItems(constructTabItems()); + }, [isDynamicEnabled, component?.id]); + + const handleToggleColumnPopover = (index) => { + setActiveColumnPopoverIndex(index); + }; + + const _renderTabOptions = () => { + return ( + + { + onDragEnd(result); + }} + > + + {({ innerRef, droppableProps, placeholder }) => ( +
+ {tabItems?.map((item, index) => { + return ( + + {(provided, snapshot) => ( +
+ { + if (show) { + handleToggleColumnPopover(index); + } else { + handleToggleColumnPopover(null); + } + }} + > +
+ setHoveredTabItemIndex(index)} + onMouseLeave={() => setHoveredTabItemIndex(null)} + className={activeColumnPopoverIndex === index && 'active-column-list'} + {...restProps} + > +
+
+ +
+
+ {resolveReferences(item.title, currentState)} +
+
+ {index === hoveredTabItemIndex && ( + { + e.stopPropagation(); + handleDeleteTabItem(index); + }} + > + + + + + )} +
+
+
+
+
+
+ )} +
+ ); + })} + {placeholder} +
+ )} +
+
+ + Add new tab + +
+ ); + }; + + let items = []; + + if (properties.length > 0) { + items.push({ + title: 'Options', + isOpen: true, + children: isDynamicEnabled ? ( + properties?.map((property) => + renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode + ) + ) + ) : ( + <> + {renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + 'useDynamicOptions', + 'properties', + currentState, + allComponents + )} + {_renderTabOptions()} + + ), + }); + } + + items.push({ + title: 'Events', + isOpen: true, + children: ( + + ), + }); + + items.push({ + title: `Additional Actions`, + isOpen: true, + children: additionalActions.map((property) => { + return renderElement( + component, + componentMeta, + paramUpdated, + dataQueries, + property, + 'properties', + currentState, + allComponents, + darkMode, + componentMeta.properties?.[property]?.placeholder + ); + }), + }); + + items.push({ + title: 'Devices', + isOpen: true, + children: ( + <> + {renderElement( + component, + componentMeta, + layoutPropertyChanged, + dataQueries, + 'showOnDesktop', + 'others', + currentState, + allComponents + )} + {renderElement( + component, + componentMeta, + layoutPropertyChanged, + dataQueries, + 'showOnMobile', + 'others', + currentState, + allComponents + )} + + ), + }); + + return ; +} diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx index de1da44195..4ed1f7a026 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ColumnPopover.jsx @@ -76,7 +76,10 @@ export const ColumnPopoverContent = ({
- + {activeTab === 'propertiesTab' ? ( { + const ColumnIcon = getColumnIcon(props.data.value); + const isDeprecated = checkIfTableColumnDeprecated(props.data.value); + + return ( + + +
+
+ {ColumnIcon && } + {props.label} +
+
+ {props.isSelected && ( + + + + )} + {isDeprecated && ( + + + + )} +
+
+
+
+ ); +}; + +const CustomValueContainer = ({ data, ...props }) => { + const Icon = getColumnIcon(data.value); + return ( +
+ {Icon && } + {data.label} +
+ ); +}; export const PropertiesTabElements = ({ column, @@ -54,6 +100,7 @@ export const PropertiesTabElements = ({ { label: 'Link', value: 'link' }, { label: 'JSON', value: 'json' }, { label: 'Markdown', value: 'markdown' }, + { label: 'HTML', value: 'html' }, // Following column types are deprecated { label: 'Default', value: 'default' }, { label: 'Dropdown', value: 'dropdown' }, @@ -64,7 +111,11 @@ export const PropertiesTabElements = ({ { label: 'Multiple badges', value: 'badges' }, { label: 'Tags', value: 'tags' }, ]} - components={{ DropdownIndicator, Option }} + components={{ + DropdownIndicator, + Option: CustomOption, + SingleValue: CustomValueContainer, + }} onChange={(value) => { onColumnItemChange(index, 'columnType', value); }} diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx index 2b91a8dd15..e1d06686d1 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/StylesTabElements.jsx @@ -129,6 +129,7 @@ export const StylesTabElements = ({ 'number', 'json', 'markdown', + 'html', 'boolean', 'select', 'text', @@ -147,7 +148,7 @@ export const StylesTabElements = ({ property="textColor" props={column} component={component} - paramMeta={{ type: 'color', displayName: 'Text color' }} + paramMeta={{ type: 'colorSwatches', displayName: 'Text color' }} paramType="properties" />
@@ -162,7 +163,7 @@ export const StylesTabElements = ({ property="cellBackgroundColor" props={column} component={component} - paramMeta={{ type: 'color', displayName: 'Cell color' }} + paramMeta={{ type: 'colorSwatches', displayName: 'Cell color' }} paramType="properties" />
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx index c3fb47d612..da6ee1d34c 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ProgramaticallyHandleProperties.jsx @@ -34,6 +34,8 @@ export const ProgramaticallyHandleProperties = ({ return props.linkColor; case 'useDynamicOptions': return props?.useDynamicOptions; + case 'autoAssignColors': + return props?.autoAssignColors; case 'makeDefaultOption': return props?.[index]?.makeDefaultOption; case 'textColor': @@ -52,6 +54,10 @@ export const ProgramaticallyHandleProperties = ({ return props?.isDateSelectionEnabled; case 'jsonIndentation': return props?.jsonIndentation; + case 'labelColor': + return props?.labelColor; + case 'optionColor': + return props?.optionColor; default: return; } @@ -74,6 +80,14 @@ export const ProgramaticallyHandleProperties = ({ if (property === 'textColor') { return definitionObj?.value ?? '#11181C'; } + if (property === 'labelColor') { + // return definitionObj?.value ?? 'var(--cc-primary-text)'; + return definitionObj?.value ?? '#1B1F24'; + } + if (property === 'optionColor') { + // return definitionObj?.value ?? 'var(--cc-surface2-surface)'; + return definitionObj?.value ?? '#E4E7EB'; + } if (property === 'underlineColor') { return definitionObj?.value ?? '#4368E3'; } diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx index 1d6860ecd1..494b2e1b55 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx @@ -9,6 +9,7 @@ import Popover from 'react-bootstrap/Popover'; import CodeHinter from '@/AppBuilder/CodeEditor'; import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties'; import { resolveReferences } from '@/_helpers/utils'; +import { Button as ButtonComponent } from '@/components/ui/Button/Button'; import { unset } from 'lodash'; export const OptionsList = ({ column, @@ -141,12 +142,23 @@ export const OptionsList = ({ props.paramUpdated({ name: 'columns' }, 'value', newColumns, 'properties', true); }; + + const handleOptionColorChange = (index, property, value) => { + handleSelectOption(option, optionIndex, value, index, property); + }; + return ( e.stopPropagation()} - style={{ zIndex: 99999, minWidth: 200 }} + style={{ + zIndex: 99999, + minWidth: 200, + boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)', + borderRadius: '6px', + border: '1px solid var(--border-default)', + }} >
e.stopPropagation()}> @@ -167,7 +179,7 @@ export const OptionsList = ({ }} />
-
e.stopPropagation()}> +
e.stopPropagation()}> @@ -185,8 +197,34 @@ export const OptionsList = ({ }} />
+
+ +
+
+ +
+ {column?.options?.length === 0 && }
- createNewOption()}> - {/* {this.props.t('widget.Table.addNewColumn', ' Add new column')} */} + { + createNewOption(); + }} + variant="secondary" + className="tw-w-full mt-2" + width="100%" + > Add new option - +
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx index bb6c1d94bb..3fb5760d24 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx @@ -19,6 +19,24 @@ import { ProgramaticallyHandleProperties } from './ProgramaticallyHandleProperti import { ColumnPopoverContent } from './ColumnManager/ColumnPopover'; import { useAppDataStore } from '@/_stores/appDataStore'; import { checkIfTableColumnDeprecated } from './ColumnManager/DeprecatedColumnTypeMsg'; +import { + TextTypeIcon, + DatepickerTypeIcon, + SelectTypeIcon, + MultiselectTypeIcon, + BooleanTypeIcon, + ImageTypeIcon, + LinkTypeIcon, + JSONTypeIcon, + MarkdownTypeIcon, + HTMLTypeIcon, + NumberTypeIcon, + StringTypeIcon, + BadgeTypeIcon, + TagsTypeIcon, + RadioTypeIcon, +} from './_assets'; +import { getColumnIcon } from './utils'; const NON_EDITABLE_COLUMNS = ['link', 'image']; class TableComponent extends React.Component { @@ -633,6 +651,8 @@ class TableComponent extends React.Component { return 'JSON'; case 'markdown': return 'Markdown'; + case 'html': + return 'HTML'; default: capitalize(text ?? ''); } @@ -677,6 +697,7 @@ class TableComponent extends React.Component { } }} darkMode={darkMode} + showIconOnHover={true} // menuActions={[ // { // label: 'Delete', @@ -692,6 +713,7 @@ class TableComponent extends React.Component { }`} columnType={item?.columnType} isDeprecated={checkIfTableColumnDeprecated(item?.columnType)} + Icon={getColumnIcon(item?.columnType)} /> @@ -777,6 +799,7 @@ class TableComponent extends React.Component { 'showBulkUpdateActions', 'visibility', 'disabledState', + 'dynamicHeight', ]; items.push({ diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx new file mode 100644 index 0000000000..bb9c075a6e --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BadgeTypeIcon.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +const BadgeTypeIcon = ({ fill = '#ACB2B9', width = '14', className = '', viewBox = '0 0 14 14', style, height }) => ( + + + +); + +export default BadgeTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx new file mode 100644 index 0000000000..cf6b09ec04 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/BooleanTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const BooleanTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default BooleanTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx new file mode 100644 index 0000000000..22bacbd1e8 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/DatepickerTypeIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const DatepickerTypeIcon = ({ + fill = '#ACB2B9', + width = '16', + className = '', + viewBox = '0 0 16 16', + style, + height, +}) => ( + + + +); + +export default DatepickerTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx new file mode 100644 index 0000000000..eb134bf034 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/HTMLTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const HTMLTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default HTMLTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx new file mode 100644 index 0000000000..3c38af7a5d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/ImageTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const ImageTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default ImageTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx new file mode 100644 index 0000000000..ede33bb51d --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/JSONTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const JSONTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default JSONTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx new file mode 100644 index 0000000000..07033546d7 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/LinkTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const LinkTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default LinkTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx new file mode 100644 index 0000000000..2156121ca4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MarkdownTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const MarkdownTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default MarkdownTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx new file mode 100644 index 0000000000..a487f5ccb5 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/MultiselectTypeIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const MultiselectTypeIcon = ({ + fill = '#ACB2B9', + width = '16', + className = '', + viewBox = '0 0 16 16', + style, + height, +}) => ( + + + +); + +export default MultiselectTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx new file mode 100644 index 0000000000..edb5ed1e6f --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/NumberTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const NumberTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default NumberTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx new file mode 100644 index 0000000000..3cf24f146c --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/RadioTypeIcon.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const RadioTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + + +); + +export default RadioTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx new file mode 100644 index 0000000000..c90b6a22fa --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/SelectTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const SelectTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default SelectTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx new file mode 100644 index 0000000000..e5ded3d294 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/StringTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const StringTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default StringTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx new file mode 100644 index 0000000000..79b39609e4 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsTypeIcon.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const TagsTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + + +); + +export default TagsTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx new file mode 100644 index 0000000000..95b61a03a2 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TextTypeIcon.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const TextTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + +); + +export default TextTypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js new file mode 100644 index 0000000000..03ae127c26 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js @@ -0,0 +1,15 @@ +export { default as TextTypeIcon } from './TextTypeIcon'; +export { default as NumberTypeIcon } from './NumberTypeIcon'; +export { default as StringTypeIcon } from './StringTypeIcon'; +export { default as DatepickerTypeIcon } from './DatepickerTypeIcon'; +export { default as SelectTypeIcon } from './SelectTypeIcon'; +export { default as MultiselectTypeIcon } from './MultiselectTypeIcon'; +export { default as BooleanTypeIcon } from './BooleanTypeIcon'; +export { default as ImageTypeIcon } from './ImageTypeIcon'; +export { default as LinkTypeIcon } from './LinkTypeIcon'; +export { default as JSONTypeIcon } from './JSONTypeIcon'; +export { default as MarkdownTypeIcon } from './MarkdownTypeIcon'; +export { default as HTMLTypeIcon } from './HTMLTypeIcon'; +export { default as BadgeTypeIcon } from './BadgeTypeIcon'; +export { default as TagsTypeIcon } from './TagsTypeIcon'; +export { default as RadioTypeIcon } from './RadioTypeIcon'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js new file mode 100644 index 0000000000..9af3ed7f05 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js @@ -0,0 +1,60 @@ +import { + TextTypeIcon, + DatepickerTypeIcon, + SelectTypeIcon, + MultiselectTypeIcon, + BooleanTypeIcon, + ImageTypeIcon, + LinkTypeIcon, + JSONTypeIcon, + MarkdownTypeIcon, + HTMLTypeIcon, + NumberTypeIcon, + StringTypeIcon, + BadgeTypeIcon, + TagsTypeIcon, + RadioTypeIcon, +} from './_assets'; + +export const getColumnIcon = (columnType) => { + switch (columnType) { + case 'default': + case 'string': + return StringTypeIcon; + case 'number': + return NumberTypeIcon; + case 'text': + return TextTypeIcon; + case 'datepicker': + return DatepickerTypeIcon; + case 'dropdown': + case 'select': + return SelectTypeIcon; + case 'multiselect': + case 'newMultiSelect': + return MultiselectTypeIcon; + case 'boolean': + case 'toggle': + return BooleanTypeIcon; + case 'image': + return ImageTypeIcon; + case 'link': + return LinkTypeIcon; + case 'json': + return JSONTypeIcon; + case 'markdown': + return MarkdownTypeIcon; + case 'html': + return HTMLTypeIcon; + case 'radio': + return RadioTypeIcon; + case 'badges': + return BadgeTypeIcon; + case 'badge': + return BadgeTypeIcon; + case 'tags': + return TagsTypeIcon; + default: + return null; + } +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx index f2b0ff7594..2474fd96c6 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx @@ -20,6 +20,7 @@ export const Code = ({ placeholder, validationFn, isHidden = false, + setCodeEditorView, customMeta, }) => { const currentState = useCurrentState(); @@ -55,7 +56,7 @@ export const Code = ({ if (isHidden) return null; return ( -
+
); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx index a5f5bb2202..60c0118855 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/EventManager.jsx @@ -30,6 +30,7 @@ import { appService } from '@/_services'; import { deepClone } from '@/_helpers/utilities/utils.helpers'; import useStore from '@/AppBuilder/_stores/store'; import { useEventActions, useEvents } from '@/AppBuilder/_stores/slices/eventsSlice'; +import { get } from 'lodash'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup'; import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem'; @@ -437,8 +438,8 @@ export const EventManager = ({ const newParams = params.length > 0 ? params.map((paramOfParamList) => { - return paramOfParamList.handle === param.handle ? newParam : paramOfParamList; - }) + return paramOfParamList.handle === param.handle ? newParam : paramOfParamList; + }) : [newParam]; return handlerChanged(index, 'componentSpecificActionParams', newParams); @@ -467,7 +468,7 @@ export const EventManager = ({ if (data.label === 'run-action') return; return (
); @@ -987,51 +988,60 @@ export const EventManager = ({
{event?.componentId && event?.componentSpecificActionHandle && - (getAction(event?.componentId, event?.componentSpecificActionHandle)?.params ?? []).map((param) => ( -
-
- {param?.displayName} + (getAction(event?.componentId, event?.componentSpecificActionHandle)?.params ?? []).map((param) => { + let optionsList = param.isDynamicOpiton + ? get({ ...components[event?.componentId] }, param.optionsGetter, []).map((tab) => ({ + name: tab.title, + value: tab.id, + })) + : param.options; + + return ( +
+
+ {param?.displayName} +
+ + {param.type === 'select' ? ( +
+ { - onChangeHandlerForComponentSpecificActionHandle(value, index, param, event); - }} - placeholder={t('globals.select', 'Select') + '...'} - styles={styles} - useMenuPortal={false} - useCustomStyles={true} - /> -
- ) : ( -
- { - onChangeHandlerForComponentSpecificActionHandle(value, index, param, event); - }} - paramLabel={' '} - paramType={param?.type} - fieldMeta={{ options: param?.options }} - cyLabel={`event-${param.displayName}`} - component={component} - isEventManagerParam={true} - /> -
- )} -
- ))} + ); + })} )}
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx index a1a24eee9c..cf1558c893 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Inspector.jsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from 'react'; import { Table } from './Components/Table/Table.jsx'; +import { TabsLayout } from './Components/TabComponent'; import { Chart } from './Components/Chart'; -import { Form } from './Components/Form'; +import Form from './Components/Form/index.js'; import { renderElement, renderCustomStyles } from './Utils'; import { toast } from 'react-hot-toast'; import { validateQueryName, convertToKebabCase, resolveReferences } from '@/_helpers/utils'; @@ -43,6 +44,9 @@ import useStore from '@/AppBuilder/_stores/store'; import { componentTypes } from '@/AppBuilder/WidgetManager/componentTypes'; import { copyComponents } from '@/AppBuilder/AppCanvas/appCanvasUtils.js'; import DatetimePickerV2 from './Components/DatetimePickerV2.jsx'; +import { ToolTip } from '@/_components/ToolTip'; +import AppPermissionsModal from '@/modules/Appbuilder/components/AppPermissionsModal'; +import { appPermissionService } from '@/_services'; import { ModuleContainerInspector, ModuleViewerInspector, ModuleEditorBanner } from '@/modules/Modules/components'; const INSPECTOR_HEADER_OPTIONS = [ @@ -61,6 +65,19 @@ const INSPECTOR_HEADER_OPTIONS = [ value: 'duplicate', icon: , }, + { + label: 'Component permission', + value: 'permission', + icon: ( + permission-icon + ), + trailingIcon: , + }, { label: 'Delete', value: 'delete', @@ -81,6 +98,9 @@ const NEW_REVAMPED_COMPONENTS = [ 'ToggleSwitchV2', 'Checkbox', 'DatetimePickerV2', + 'DatePickerV2', + 'TimePicker', + 'DaterangePicker', 'DropdownV2', 'MultiselectV2', 'RadioButtonV2', @@ -91,8 +111,11 @@ const NEW_REVAMPED_COMPONENTS = [ 'Divider', 'VerticalDivider', 'ModalV2', + 'Tabs', + 'RangeSlider', 'Link', 'Steps', + 'FilePicker', ]; export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selectedComponentId }) => { @@ -104,6 +127,11 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte const isVersionReleased = useStore((state) => state.isVersionReleased); const setWidgetDeleteConfirmation = useStore((state) => state.setWidgetDeleteConfirmation); const setComponentToInspect = useStore((state) => state.setComponentToInspect); + const featureAccess = useStore((state) => state?.license?.featureAccess, shallow); + const licenseValid = !featureAccess?.licenseStatus?.isExpired && featureAccess?.licenseStatus?.isLicenseValid; + const showComponentPermissionModal = useStore((state) => state.showComponentPermissionModal); + const toggleComponentPermissionModal = useStore((state) => state.toggleComponentPermissionModal); + const setComponentPermission = useStore((state) => state.setComponentPermission); const dataQueries = useDataQueries(); const currentState = useCurrentState(); @@ -378,9 +406,14 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte if (value === 'delete') { setWidgetDeleteConfirmation(true); } + if (value === 'permission') { + if (!licenseValid) return; + toggleComponentPermissionModal(true); + } if (value === 'duplicate') { copyComponents({ isCloning: true }); } + setShowHeaderActionsMenu(false); }; const buildGeneralStyle = () => { if (!componentMeta?.definition?.generalStyles) { @@ -446,7 +479,7 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte React.useEffect(() => { const handleClickOutside = (event) => { - if (showHeaderActionsMenu && event.target.closest('.list-menu') === null) { + if (showHeaderActionsMenu && event.target.closest('#list-menu') === null) { setShowHeaderActionsMenu(false); } }; @@ -458,6 +491,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify({ showHeaderActionsMenu })]); + const toggleRightSidebarPin = useStore((state) => state.toggleRightSidebarPin); + const isRightSidebarPinned = useStore((state) => state.isRightSidebarPinned); const renderAppNameInput = () => { if (isModuleContainer) { return ; @@ -504,44 +539,79 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte
{renderAppNameInput()}
{!isModuleContainer && ( -
- - - {INSPECTOR_HEADER_OPTIONS.map((option) => ( -
{ - e.stopPropagation(); - handleInspectorHeaderActions(option.value); - }} - > -
{option.icon}
-
- {option?.label} -
-
- ))} -
- - } - > - setShowHeaderActionsMenu(true)}> - - -
-
+ <> +
+ + + {INSPECTOR_HEADER_OPTIONS.map((option) => { + const optionBody = ( +
{ + e.stopPropagation(); + handleInspectorHeaderActions(option.value); + }} + > +
{option.icon}
+
+ {option?.label} +
+ {option.value === 'permission' && + !licenseValid && + option.trailingIcon && + option.trailingIcon} +
+ ); + + return option.value === 'permission' ? ( + + {optionBody} + + ) : ( + optionBody + ); + })} +
+ + } + > + setShowHeaderActionsMenu(true)}> + + +
+
+ appPermissionService.getComponentPermission(appId, id)} + createPermission={(id, appId, body) => appPermissionService.createComponentPermission(appId, id, body)} + updatePermission={(id, appId, body) => appPermissionService.updateComponentPermission(appId, id, body)} + deletePermission={(id, appId) => appPermissionService.deleteComponentPermission(appId, id)} + onSuccess={(data) => setComponentPermission(selectedComponentId, data)} + /> + )}
@@ -557,8 +627,8 @@ export const Inspector = ({ componentDefinitionChanged, darkMode, pages, selecte componentMeta.displayName === 'Toggle Switch (Legacy)' ? 'Toggle (Legacy)' : componentMeta.displayName === 'Toggle Switch' - ? 'Toggle Switch' - : componentMeta.component, + ? 'Toggle Switch' + : componentMeta.component, })} @@ -727,6 +797,9 @@ const GetAccordion = React.memo( case 'Table': return ; + case 'Tabs': + return ; + case 'Chart': return ; @@ -746,7 +819,7 @@ const GetAccordion = React.memo( return ; case 'Form': - return
; + return ; case 'DropdownV2': case 'MultiselectV2': diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js index a9b981eb1a..0e4128729c 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js @@ -55,7 +55,9 @@ export function renderCustomStyles( componentConfig.component == 'RadioButtonV2' || componentConfig.component == 'Button' || componentConfig.component == 'Image' || - componentConfig.component == 'ModalV2' + componentConfig.component == 'ModalV2' || + componentConfig.component == 'RangeSlider' || + componentConfig.component == 'FilePicker' ) { const paramTypeConfig = componentMeta[paramType] || {}; const paramConfig = paramTypeConfig[param] || {}; @@ -131,7 +133,8 @@ export function renderElement( darkMode = false, placeholder = '', validationFn, - customMeta + setCodeEditorView = null, + customMeta = null ) { const componentConfig = component.component; const componentDefinition = componentConfig.definition; @@ -144,7 +147,8 @@ export function renderElement( componentConfig.component == 'DropDown' || componentConfig.component == 'Form' || componentConfig.component == 'Listview' || - componentConfig.component == 'Image' + componentConfig.component == 'Image' || + componentConfig.component == 'RangeSlider' ) { const paramTypeConfig = componentMeta[paramType] || {}; const paramConfig = paramTypeConfig[param] || {}; @@ -179,6 +183,7 @@ export function renderElement( placeholder={placeholder} validationFn={validationFn} isHidden={isHidden} + setCodeEditorView={setCodeEditorView} customMeta={customMeta} /> ); diff --git a/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx new file mode 100644 index 0000000000..261ab9d0d1 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPageMenu.jsx @@ -0,0 +1,107 @@ +import React, { useRef, useState } from 'react'; +import { Overlay, Popover } from 'react-bootstrap'; +import { Button } from '@/components/ui/Button/Button'; +import useStore from '@/AppBuilder/_stores/store'; +import { AddEditPagePopup } from './AddNewPagePopup'; +import PageOptions from './PageOptions'; +import { ToolTip as LicenseTooltip } from '@/_components/ToolTip'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; + +export function AddNewPageMenu({ darkMode, isLicensed }) { + const newPageBtnRef = useRef(null); + const [showMenuPopover, setShowMenuPopover] = useState(false); + const setNewPagePopupConfig = useStore((state) => state.setNewPagePopupConfig); + const setEditingPage = useStore((state) => state.setEditingPage); + const newPagePopupConfig = useStore((state) => state.newPagePopupConfig); + + const handleOpenPopup = (type) => { + setShowMenuPopover(false); + setNewPagePopupConfig({ type, show: true, mode: 'add' }); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx new file mode 100644 index 0000000000..277ba12bbf --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/PageSettingsTab/PageMenu/AddNewPagePopup.jsx @@ -0,0 +1,654 @@ +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; +import cx from 'classnames'; +import { Popover } from 'react-bootstrap'; +import useStore from '@/AppBuilder/_stores/store'; +import SolidIcon from '@/_ui/Icon/SolidIcons'; +import { Button } from '@/_ui/LeftSidebar'; +import { Icon } from '@/AppBuilder/CodeBuilder/Elements/Icon'; +import { EventManager } from '../../Inspector/EventManager'; +import { kebabCase } from 'lodash'; +import Select from '@/_ui/Select'; +import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup'; +import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem'; +import { appService } from '@/_services'; +import { ToolTip } from '@/_components'; +import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import FxButton from '@/Editor/CodeBuilder/Elements/FxButton'; +import { resolveReferences, validateKebabCase } from '@/_helpers/utils'; +import { ToolTip as InspectorTooltip } from '../../Inspector/Elements/Components/ToolTip'; + +const POPOVER_TITLES = { + add: { + default: 'New page', + app: 'New nav item with app', + url: 'New nav item with URL', + group: 'New nav group', + }, + edit: { + default: 'Edit page', + app: 'Edit nav item', + url: 'Edit nav item', + group: 'Edit nav group', + }, +}; + +const OPEN_APP_MODES = [ + { label: 'New tab', value: 'new_tab' }, + { label: 'Same tab', value: 'same_tab' }, +]; + +const POPOVER_ACTIONS = { + default: 'page', + url: 'page', + app: 'page', + group: 'group', +}; + +export const AddEditPagePopup = forwardRef(({ darkMode, ...props }, ref) => { + const { moduleId } = useModuleContext(); + const { show, mode, type } = useStore((state) => state.newPagePopupConfig); + const editingPage = useStore((state) => state.editingPage); + const pages = useStore((state) => state?.modules?.canvas?.pages ?? []); + const addNewPage = useStore((state) => state.addNewPage); + const updatePageName = useStore((state) => state.updatePageName); + const updatePageHandle = useStore((state) => state.updatePageHandle); + const updatePageTarget = useStore((state) => state.updatePageTarget); + const updatePageURL = useStore((state) => state.updatePageURL); + const updatePageIcon = useStore((state) => state.updatePageIcon); + const markAsHomePage = useStore((state) => state.markAsHomePage); + const clonePage = useStore((state) => state.clonePage); + const cloneGroup = useStore((state) => state.cloneGroup); + const toggleDeleteConfirmationModal = useStore((state) => state.toggleDeleteConfirmationModal); + const switchPage = useStore((state) => state.switchPage); + + const isPageGroup = useStore((state) => state.isPageGroup); + const homePageId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + const updatePageVisibility = useStore((state) => state.updatePageVisibility); + const disableOrEnablePage = useStore((state) => state.disableOrEnablePage); + const updatePageAppId = useStore((state) => state.updatePageAppId); + const currentPageId = useStore((state) => state.currentPageId); + const setCurrentPageHandle = useStore((state) => state.setCurrentPageHandle); + const openPageEditPopover = useStore((state) => state.openPageEditPopover); + const appId = useStore((state) => state.appStore.modules[moduleId].app.homePageId); + + const [page, setPage] = useState(editingPage || props?.page); + const [pageName, setPageName] = useState(''); + const [handle, setHandle] = useState(''); + const [pageURL, setPageURL] = useState(''); + const [hasAutoSaved, setHasAutoSaved] = useState(false); + const [error, setError] = useState(null); + + const allpages = pages.filter((p) => p.id !== page?.id); + const isHomePage = page?.id === homePageId; + + //Nav item with app + const [appOptions, setAppOptions] = useState([]); + const [appOptionsLoading, setAppOptionsLoading] = useState(true); + + useEffect(() => { + setError(null); + }, [show]); + + useEffect(() => { + if (mode === 'add' && type === 'default' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `Page ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `Page ${index}`; + } + const pageObj = { type: 'default' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + setHandle(data?.handle); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + setHandle(editingPage.handle); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type]); + + //Nav item with URL hooks + useEffect(() => { + if (mode === 'add' && type === 'url' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `URL ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `URL ${index}`; + } + const pageObj = { type: 'url', openIn: 'new_tab', url: 'https://www.tooljet.ai' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + setPageURL(data?.url); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + setPageURL(editingPage.url); + } + }, [addNewPage, appOptions, editingPage, hasAutoSaved, isPageGroup, mode, pages, type]); + + //Nav item with app hooks + useEffect(() => { + const fetchApps = async (page) => { + const { apps } = await appService.getAll(page); + return apps; + }; + + // eslint-disable-next-line no-inner-declarations + async function getAllApps() { + const apps = await fetchApps(0); + let appsOptionsList = []; + apps + .filter((item) => item.slug !== undefined && item.id !== appId && item.current_version_id) + .forEach((item) => { + appsOptionsList.push({ + name: item.name, + value: item.slug, + }); + }); + return appsOptionsList; + } + + getAllApps() + .then((apps) => { + setAppOptions(apps); + }) + .finally(() => { + setAppOptionsLoading(false); + }); + if (mode === 'add' && type === 'app' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `App ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `App ${index}`; + } + const pageObj = { type: 'app', openIn: 'new_tab' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), isPageGroup, pageObj).then((data) => { + setPage(data); + setPageName(newName); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type, appId]); + + //Nav item with group + useEffect(() => { + if (mode === 'add' && type === 'group' && !hasAutoSaved) { + const existingNames = pages.map((p) => p.name.toLowerCase()); + let index = 1; + let newName = `Group ${index}`; + while (existingNames.includes(newName.toLowerCase())) { + index++; + newName = `Group ${index}`; + } + const pageObj = { type: 'group', openIn: 'new_tab' }; + addNewPage(newName, kebabCase(newName.toLowerCase()), true, pageObj).then((data) => { + setPage(data); + setPageName(newName); + }); + + setHasAutoSaved(true); + } else if (editingPage) { + setPage(editingPage); + setPageName(editingPage.name); + } + }, [mode, hasAutoSaved, pages, editingPage, addNewPage, isPageGroup, type, appId]); + + const handlePageSwitch = useCallback(() => { + if (currentPageId === page.id) { + return; + } + switchPage(page.id, page.handle); + setCurrentPageHandle(page.handle); + }, [currentPageId, page?.id, page?.handle, switchPage, setCurrentPageHandle]); + + const onChangePageHandleValue = (event) => { + setError(null); + const newHandle = event.target.value; + + if (newHandle === '') setError('Page handle cannot be empty'); + if (newHandle === handle) setError('Page handle cannot be same as the existing page handle'); + const isValidKebabCase = validateKebabCase(newHandle); + if (!isValidKebabCase.isValid) { + setError(isValidKebabCase.error); + } + setHandle(newHandle); + }; + + const handleSave = () => { + if (handle === page.handle) { + setError(null); + return; + } + const { isValid, error } = validateKebabCase(handle); + if (!isValid) { + setError(error); + return; + } + const transformedPageHandle = kebabCase(handle); + updatePageHandle(page.id, transformedPageHandle); + setError(null); + }; + + return ( + + +
+
{POPOVER_TITLES?.[mode]?.[type]}
+
+ {type !== 'group' && ( + <> + +
+ +
+
+ + )} + + +
(type === 'group' ? cloneGroup(page?.id) : clonePage(page?.id))} className="icon-btn"> + +
+
+ + +
{ + openPageEditPopover(page); + toggleDeleteConfirmationModal(true); + }} + className="icon-btn" + > + +
+
+
+
+
+ + {type === 'default' && ( + <> +
+
+ + setPageName(e.target.value)} + onBlur={(e) => { + pageName && pageName !== page?.name && updatePageName(page?.id, pageName); + }} + minLength="1" + /> +
+
+
+
+ + onChangePageHandleValue(e)} + onBlur={(e) => handleSave(e)} + value={handle} + minLength="1" + /> +
+ {error} +
+
+
+
+
+ + updatePageIcon(page?.id, value)} + value={page?.icon || 'IconFile'} + /> +
+
+
+
+ + +
+ {/*
+ + +
*/} +
+ + + )} + {type === 'url' && ( + <> +
+
+ + setPageName(e.target.value)} + className="form-control" + value={pageName} + autoFocus={true} + onBlur={(e) => { + pageName && pageName !== page?.name && updatePageName(page?.id, pageName); + }} + minLength="1" + /> +
+
+
+
+ +