diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/IDE/Command_Click_Navigation_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/IDE/Command_Click_Navigation_spec.js index aa38118d55..b927cfe968 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/IDE/Command_Click_Navigation_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/IDE/Command_Click_Navigation_spec.js @@ -95,6 +95,7 @@ describe("1. CommandClickNavigation", function() { cy.get(`[${NAVIGATION_ATTRIBUTE}="Graphql_Query"]`).click({ ctrlKey: true, + force: true, }); cy.url().should("contain", "/api/"); diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/PeekOverlay/PeekOverlay.spec.ts b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/PeekOverlay/PeekOverlay.spec.ts new file mode 100644 index 0000000000..1421ffe914 --- /dev/null +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/PeekOverlay/PeekOverlay.spec.ts @@ -0,0 +1,124 @@ +import * as _ from "../../../../support/Objects/ObjectsCore"; + +describe("peek overlay", () => { + it("main test", () => { + _.ee.DragDropWidgetNVerify("tablewidgetv2", 500, 100); + _.apiPage.CreateAndFillApi(_.agHelper.mockApiUrl); + _.apiPage.RunAPI(); + _.apiPage.CreateAndFillApi(_.agHelper.mockApiUrl); + _.jsEditor.CreateJSObject( + `export default { + numArray: [1, 2, 3], + objectArray: [ {x: 123}, { y: "123"} ], + objectData: { x: 123, y: "123" }, + nullData: null, + numberData: 1, + myFun1: () => { + // TODO: handle this keyword failure on CI tests + JSObject1.numArray; JSObject1.objectData; JSObject1.nullData; JSObject1.numberData; + Api1.run(); Api1.isLoading; Api2.data; + appsmith.mode; appsmith.store.abc; + Table1.pageNo; Table1.tableData; + }, + myFun2: async () => { + storeValue("abc", 123) + return Api1.run() + } + }`, + { + paste: true, + completeReplace: true, + toRun: false, + shouldCreateNewJSObj: true, + lineNumber: 0, + prettify: true, + }, + ); + _.jsEditor.SelectFunctionDropdown("myFun2"); + _.jsEditor.RunJSObj(); + _.agHelper.Sleep(); + + // check number array + _.peekOverlay.HoverCode("JSObject1.numArray"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("array"); + _.peekOverlay.CheckPrimitveArrayInOverlay([1, 2, 3]); + _.peekOverlay.ResetHover(); + + // check basic object + _.peekOverlay.HoverCode("JSObject1.objectData"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("object"); + _.peekOverlay.CheckBasicObjectInOverlay({ x: 123, y: "123" }); + _.peekOverlay.ResetHover(); + + // check null - with this keyword + _.peekOverlay.HoverCode("JSObject1.nullData"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("null"); + _.peekOverlay.CheckPrimitiveValue("null"); + _.peekOverlay.ResetHover(); + + // check number + _.peekOverlay.HoverCode("JSObject1.numberData"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("number"); + _.peekOverlay.CheckPrimitiveValue("1"); + _.peekOverlay.ResetHover(); + + // check undefined + _.peekOverlay.HoverCode("Api2.data"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("undefined"); + _.peekOverlay.CheckPrimitiveValue("undefined"); + _.peekOverlay.ResetHover(); + + // check boolean + _.peekOverlay.HoverCode("Api1.isLoading"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("boolean"); + _.peekOverlay.CheckPrimitiveValue("false"); + _.peekOverlay.ResetHover(); + + // TODO: handle this function failure on CI tests -> "function(){}" + // check function + // _.peekOverlay.HoverCode("Api1.run"); + // _.peekOverlay.IsOverlayOpen(); + // _.peekOverlay.VerifyDataType("function"); + // _.peekOverlay.CheckPrimitiveValue("function () {}"); + // _.peekOverlay.ResetHover(); + + // check string + _.peekOverlay.HoverCode("appsmith.mode"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("string"); + _.peekOverlay.CheckPrimitiveValue("EDIT"); + _.peekOverlay.ResetHover(); + + // check if overlay closes + _.peekOverlay.HoverCode("appsmith.store"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.ResetHover(); + _.peekOverlay.IsOverlayOpen(false); + + // widget object + _.peekOverlay.HoverCode("Table1"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("object"); + _.peekOverlay.ResetHover(); + + // widget property + _.peekOverlay.HoverCode("Table1.pageNo"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("number"); + _.peekOverlay.CheckPrimitiveValue("1"); + _.peekOverlay.ResetHover(); + + // widget property + _.peekOverlay.HoverCode("Table1.tableData"); + _.peekOverlay.IsOverlayOpen(); + _.peekOverlay.VerifyDataType("array"); + _.peekOverlay.CheckObjectArrayInOverlay([{}, {}, {}]); + _.peekOverlay.ResetHover(); + }); +}); diff --git a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/Filepicker/FilePickerV2_spec.js b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/Filepicker/FilePickerV2_spec.js index e879f1dfd8..632a331855 100644 --- a/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/Filepicker/FilePickerV2_spec.js +++ b/app/client/cypress/integration/Regression_TestSuite/ClientSideTests/Widgets/Filepicker/FilePickerV2_spec.js @@ -67,9 +67,7 @@ describe("File picker widget v2", () => { cy.get(widgetsPage.explorerSwitchId).click(); ee.ExpandCollapseEntity("Queries/JS"); cy.get(".t--entity-item:contains(Api1)").click(); - cy.get("[class*='t--actionConfiguration']") - .eq(0) - .click(); + cy.focusCodeInput("[class*='t--actionConfiguration']"); cy.wait(1000); cy.validateEvaluatedValue("[]"); }); diff --git a/app/client/cypress/support/Objects/CommonLocators.ts b/app/client/cypress/support/Objects/CommonLocators.ts index 0c596b1a45..5a5301c668 100644 --- a/app/client/cypress/support/Objects/CommonLocators.ts +++ b/app/client/cypress/support/Objects/CommonLocators.ts @@ -174,4 +174,5 @@ export class CommonLocators { _consoleString = ".cm-string"; _commentString = ".cm-comment"; _modalWrapper = "[data-cy='modal-wrapper']"; + _editorBackButton = ".t--close-editor"; } diff --git a/app/client/cypress/support/Objects/ObjectsCore.ts b/app/client/cypress/support/Objects/ObjectsCore.ts index d87a0c81f1..f3cf389337 100644 --- a/app/client/cypress/support/Objects/ObjectsCore.ts +++ b/app/client/cypress/support/Objects/ObjectsCore.ts @@ -19,3 +19,4 @@ export const inviteModal = ObjectsRegistry.InviteModal; export const table = ObjectsRegistry.TableV2; export const debuggerHelper = ObjectsRegistry.DebuggerHelper; export const templates = ObjectsRegistry.Templates; +export const peekOverlay = ObjectsRegistry.PeekOverlay; diff --git a/app/client/cypress/support/Objects/Registry.ts b/app/client/cypress/support/Objects/Registry.ts index 474a1db585..9aa241787e 100644 --- a/app/client/cypress/support/Objects/Registry.ts +++ b/app/client/cypress/support/Objects/Registry.ts @@ -13,6 +13,7 @@ import { GitSync } from "../Pages/GitSync"; import { FakerHelper } from "../Pages/FakerHelper"; import { DebuggerHelper } from "../Pages/DebuggerHelper"; import { LibraryInstaller } from "../Pages/LibraryInstaller"; +import { PeekOverlay } from "../Pages/PeekOverlay"; import { InviteModal } from "../Pages/InviteModal"; import { AppSettings } from "../Pages/AppSettings/AppSettings"; import { GeneralSettings } from "../Pages/AppSettings/GeneralSettings"; @@ -182,6 +183,14 @@ export class ObjectsRegistry { return ObjectsRegistry.LibraryInstaller__; } + private static peekOverlay__: PeekOverlay; + static get PeekOverlay(): PeekOverlay { + if (ObjectsRegistry.peekOverlay__ === undefined) { + ObjectsRegistry.peekOverlay__ = new PeekOverlay(); + } + return ObjectsRegistry.peekOverlay__; + } + private static inviteModal__: InviteModal; static get InviteModal(): InviteModal { if (ObjectsRegistry.inviteModal__ === undefined) { diff --git a/app/client/cypress/support/Pages/PeekOverlay.ts b/app/client/cypress/support/Pages/PeekOverlay.ts new file mode 100644 index 0000000000..fe8b719b64 --- /dev/null +++ b/app/client/cypress/support/Pages/PeekOverlay.ts @@ -0,0 +1,107 @@ +import { ObjectsRegistry } from "../Objects/Registry"; + +export class PeekOverlay { + private readonly PEEKABLE_ATTRIBUTE = "peek-data"; + private readonly locators = { + _overlayContainer: "#t--peek-overlay-container", + _dataContainer: "#t--peek-overlay-data", + _peekableCode: (peekableAttr: string) => + `[${this.PEEKABLE_ATTRIBUTE}="${peekableAttr}"]`, + + // react json viewer selectors + _rjv_variableValue: ".variable-value", + _rjv_topLevelArrayData: + ".pushed-content.object-container .object-content .object-key-val", + _rjv_firstLevelBraces: + ".pretty-json-container > .object-content:first-of-type > .object-key-val:first-of-type > span", + }; + private readonly agHelper = ObjectsRegistry.AggregateHelper; + + HoverCode(peekableAttribute: string, visibleText?: string) { + (visibleText + ? this.agHelper.GetNAssertContains( + this.locators._peekableCode(peekableAttribute), + visibleText, + ) + : this.agHelper.GetElement(this.locators._peekableCode(peekableAttribute)) + ).realHover(); + this.agHelper.Sleep(); + } + + IsOverlayOpen(checkIsOpen = true) { + checkIsOpen + ? this.agHelper.AssertElementExist(this.locators._overlayContainer) + : this.agHelper.AssertElementAbsence(this.locators._overlayContainer); + } + + ResetHover() { + this.agHelper.GetElement("body").realHover({ position: "bottomLeft" }); + this.agHelper.Sleep(); + } + + CheckPrimitiveValue(data: string) { + this.agHelper + .GetElement(this.locators._dataContainer) + .children("div") + .should("have.text", data); + } + + CheckPrimitveArrayInOverlay(array: Array) { + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_variableValue) + .should("have.length", array.length); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(0) + .contains("["); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(1) + .contains("]"); + } + + CheckObjectArrayInOverlay(array: Array>) { + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_topLevelArrayData) + .should("have.length", array.length); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(0) + .contains("["); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(1) + .contains("]"); + } + + CheckBasicObjectInOverlay(object: Record) { + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_variableValue) + .should("have.length", Object.entries(object).length); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(0) + .contains("{"); + this.agHelper + .GetElement(this.locators._dataContainer) + .find(this.locators._rjv_firstLevelBraces) + .eq(1) + .contains("}"); + } + + VerifyDataType(type: string) { + this.agHelper + .GetElement(this.locators._overlayContainer) + .children("div") + .eq(0) + .should("have.text", type); + } +} diff --git a/app/client/package.json b/app/client/package.json index 4743726841..7b9542f6b6 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -103,6 +103,7 @@ "rc-tree-select": "^5.4.0", "re-reselect": "^3.4.0", "react": "^17.0.2", + "react-append-to-body": "^2.0.26", "react-beautiful-dnd": "^12.2.0", "react-custom-scrollbars": "^4.2.1", "react-device-detect": "^2.2.2", diff --git a/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/Analytics.ts b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/Analytics.ts new file mode 100644 index 0000000000..29e2e2e1aa --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/Analytics.ts @@ -0,0 +1,38 @@ +import { MouseEventHandler } from "react"; +import AnalyticsUtil from "utils/AnalyticsUtil"; + +export const objectCollapseAnalytics: MouseEventHandler = (ev) => { + /* + * Analytics events to be logged whenever user clicks on + * react json viewer's controls to expand or collapse object/array + */ + const targetNode = ev.target as HTMLElement; + + if ( + // collapse/expand icon click, object key click + targetNode.parentElement?.parentElement?.parentElement?.firstElementChild?.classList.contains( + "icon-container", + ) || + // : click + targetNode.parentElement?.parentElement?.firstElementChild?.classList.contains( + "icon-container", + ) || + // { click + targetNode.parentElement?.firstElementChild?.classList.contains( + "icon-container", + ) || + // ellipsis click + targetNode.classList.contains("node-ellipsis") || + // collapse/expand icon - svg path click + targetNode.parentElement?.parentElement?.classList.contains( + "collapsed-icon", + ) || + targetNode.parentElement?.parentElement?.classList.contains("expanded-icon") + ) { + AnalyticsUtil.logEvent("PEEK_OVERLAY_COLLAPSE_EXPAND_CLICK"); + } +}; + +export const textSelectAnalytics = () => { + AnalyticsUtil.logEvent("PEEK_OVERLAY_VALUE_COPIED"); +}; diff --git a/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/JsonWrapper.tsx b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/JsonWrapper.tsx new file mode 100644 index 0000000000..6f429ffe23 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/JsonWrapper.tsx @@ -0,0 +1,91 @@ +import styled from "styled-components"; + +export const reactJsonProps = { + name: null, + enableClipboard: false, + displayDataTypes: false, + displayArrayKey: true, + quotesOnKeys: false, + style: { + fontSize: "10px", + }, + collapsed: 1, + indentWidth: 2, + collapseStringsAfterLength: 30, +}; + +export const JsonWrapper = styled.div` + // all ellipsis font size + .node-ellipsis, + .function-collapsed span:nth-child(2), + .string-value span { + font-size: 10px !important; + } + + // disable and hide first object collapser + .pretty-json-container + > .object-content:first-of-type + > .object-key-val:first-of-type + > span { + pointer-events: none !important; + .icon-container { + display: none !important; + } + } + + // collapse icon color change and alignment + .icon-container { + width: 10px !important; + height: 8px !important; + svg { + color: var(--appsmith-color-black-600) !important; + } + } + + // font-sizes and alignments + .pushed-content.object-container { + .object-content { + padding-left: 4px !important; + .variable-row { + padding-top: 0 !important; + padding-bottom: 0 !important; + border-left: 0 !important; + .variable-value div { + text-transform: lowercase; + font-size: 10px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + } + .object-key-val { + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-left: 0 !important; + border-left: 0 !important; + } + } + } + + // disabling function collapse and neutral styling + .rjv-function-container { + pointer-events: none; + font-weight: normal !important; + > span:first-child:before { + // In prod build, for some reason react-json-viewer + // misses adding this opening braces for function + content: "("; + } + .function-collapsed { + font-weight: normal !important; + span:nth-child(1) { + display: none; // hiding extra braces + } + span:nth-child(2) { + color: #393939 !important; + } + } + } + div:has(.rjv-function-container) { + cursor: default !important; + } +`; diff --git a/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/PeekOverlayPopup.tsx b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/PeekOverlayPopup.tsx new file mode 100644 index 0000000000..22c6776444 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/PeekOverlayPopup/PeekOverlayPopup.tsx @@ -0,0 +1,155 @@ +import React, { MutableRefObject, useEffect, useRef } from "react"; +import ReactJson from "react-json-view"; +import { JsonWrapper, reactJsonProps } from "./JsonWrapper"; +import { componentWillAppendToBody } from "react-append-to-body"; +import { MenuDivider } from "design-system-old"; +import { debounce } from "lodash"; +import { zIndexLayers } from "constants/CanvasEditorConstants"; +import { objectCollapseAnalytics, textSelectAnalytics } from "./Analytics"; + +export type PeekOverlayStateProps = { + name: string; + position: DOMRect; + textWidth: number; + data: unknown; + dataType: string; +}; + +/* + * using `componentWillAppendToBody` to work with variable height for peek overlay + * we need a container that doesn't apply `position: absolute` to itself with zero height (bp3-portal does this) + * Because then, child elements cannot be positioned using `bottom` property + * with `react-append-to-body`, the container won't have `position: absolute` + * instead we're applying it to the child element `
` directly, hence we can position using `bottom` property. + */ +export const PeekOverlayPopUp = componentWillAppendToBody( + PeekOverlayPopUpContent, +); + +export const PEEK_OVERLAY_DELAY = 200; + +export function PeekOverlayPopUpContent( + props: PeekOverlayStateProps & { + hidePeekOverlay: () => void; + }, +) { + const CONTAINER_MAX_HEIGHT_PX = 252; + const dataWrapperRef: MutableRefObject = useRef(null); + + useEffect(() => { + const wheelCallback = () => { + props.hidePeekOverlay(); + }; + + window.addEventListener("wheel", wheelCallback); + return () => { + window.removeEventListener("wheel", wheelCallback); + }; + }, []); + + useEffect(() => { + if (!dataWrapperRef.current) return; + dataWrapperRef.current.addEventListener("copy", textSelectAnalytics); + return () => + dataWrapperRef.current?.removeEventListener("copy", textSelectAnalytics); + }, [dataWrapperRef, dataWrapperRef.current]); + + const debouncedHide = debounce( + () => props.hidePeekOverlay(), + PEEK_OVERLAY_DELAY, + ); + + const getDataTypeHeader = (dataType: string) => { + if (props.dataType === "object") { + if (Array.isArray(props.data)) return "array"; + if (props.data === null) return "null"; + } + return dataType; + }; + + return ( +
debouncedHide.cancel()} + onMouseLeave={() => debouncedHide()} + onWheel={(ev) => ev.stopPropagation()} + style={{ + minHeight: "46px", + maxHeight: `${CONTAINER_MAX_HEIGHT_PX}px`, + width: "300px", + backgroundColor: "var(--appsmith-color-black-0)", + boxShadow: "0px 0px 10px #0000001A", // color used from designs + left: `${props.position.left + props.position.width - 300}px`, + ...(props.position.top >= CONTAINER_MAX_HEIGHT_PX + ? { + bottom: `calc(100vh - ${props.position.top}px)`, + } + : { + top: `${props.position.bottom}px`, + }), + }} + > +
+ {getDataTypeHeader(props.dataType)} +
+ +
+ {props.dataType === "object" && props.data !== null && ( + + + + )} + {props.dataType === "function" && ( +
{(props.data as any).toString()}
+ )} + {props.dataType === "boolean" && ( +
{(props.data as any).toString()}
+ )} + {props.dataType === "string" && ( +
{(props.data as any).toString()}
+ )} + {props.dataType === "number" && ( +
{(props.data as any).toString()}
+ )} + {((props.dataType !== "object" && + props.dataType !== "function" && + props.dataType !== "boolean" && + props.dataType !== "string" && + props.dataType !== "number") || + props.data === null) && ( +
+ {(props.data as any)?.toString() ?? + props.data ?? + props.data === undefined + ? "undefined" + : "null"} +
+ )} +
+
+ ); +} diff --git a/app/client/src/components/editorComponents/CodeEditor/index.tsx b/app/client/src/components/editorComponents/CodeEditor/index.tsx index 40793ee5f9..dd6a5d3b25 100644 --- a/app/client/src/components/editorComponents/CodeEditor/index.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/index.tsx @@ -23,7 +23,7 @@ import "codemirror/addon/comment/comment"; import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors"; import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup"; import { WrappedFieldInputProps } from "redux-form"; -import _, { isEqual } from "lodash"; +import _, { debounce, isEqual } from "lodash"; import { DataTree, @@ -57,6 +57,11 @@ import { bindingMarker, entityMarker, NAVIGATE_TO_ATTRIBUTE, + PEEKABLE_ATTRIBUTE, + PEEKABLE_CH_END, + PEEKABLE_CH_START, + PEEKABLE_LINE, + PEEK_STYLE_PERSIST_CLASS, } from "components/editorComponents/CodeEditor/markHelpers"; import { bindingHint } from "components/editorComponents/CodeEditor/hintHelpers"; import BindingPrompt from "./BindingPrompt"; @@ -125,6 +130,11 @@ import history, { NavigationMethod } from "utils/history"; import { selectWidgetInitAction } from "actions/widgetSelectionActions"; import { CursorPositionOrigin } from "reducers/uiReducers/editorContextReducer"; import { SelectionRequestType } from "sagas/WidgetSelectUtils"; +import { + PeekOverlayPopUp, + PeekOverlayStateProps, + PEEK_OVERLAY_DELAY, +} from "./PeekOverlayPopup/PeekOverlayPopup"; type ReduxStateProps = ReturnType; type ReduxDispatchProps = ReturnType; @@ -219,6 +229,11 @@ type State = { // Flag for determining whether the entity change has been started or not so that even if the initial and final value remains the same, the status should be changed to not loading changeStarted: boolean; ctrlPressed: boolean; + peekOverlayProps: + | (PeekOverlayStateProps & { + marker?: CodeMirror.TextMarker; + }) + | undefined; isDynamic: boolean; }; @@ -251,6 +266,7 @@ class CodeEditor extends Component { hinterOpen: false, changeStarted: false, ctrlPressed: false, + peekOverlayProps: undefined, }; this.updatePropertyValue = this.updatePropertyValue.bind(this); } @@ -365,6 +381,11 @@ class CodeEditor extends Component { editor.on("blur", this.handleEditorBlur); editor.on("postPick", () => this.handleAutocompleteVisibility(editor)); editor.on("mousedown", this.handleClick); + CodeMirror.on( + editor.getWrapperElement(), + "mousemove", + this.debounceHandleMouseOver, + ); if (this.props.height) { editor.setSize("100%", this.props.height); @@ -503,6 +524,91 @@ class CodeEditor extends Component { this.editor.clearHistory(); } + showPeekOverlay = ( + peekableAttribute: string, + tokenElement: Element, + tokenElementPosition: DOMRect, + dataToShow: unknown, + ) => { + const line = tokenElement.getAttribute(PEEKABLE_LINE), + chStart = tokenElement.getAttribute(PEEKABLE_CH_START), + chEnd = tokenElement.getAttribute(PEEKABLE_CH_END); + + this.state.peekOverlayProps?.marker?.clear(); + let marker: CodeMirror.TextMarker | undefined; + if (line && chStart && chEnd) { + marker = this.editor.markText( + { ch: Number(chStart), line: Number(line) }, + { ch: Number(chEnd), line: Number(line) }, + { + className: PEEK_STYLE_PERSIST_CLASS, + }, + ); + } + + this.setState({ + peekOverlayProps: { + name: peekableAttribute, + position: tokenElementPosition, + textWidth: tokenElementPosition.width, + marker, + data: dataToShow, + dataType: typeof dataToShow, + }, + }); + + AnalyticsUtil.logEvent("PEEK_OVERLAY_OPENED", { + property: peekableAttribute, + }); + }; + + hidePeekOverlay = () => { + this.state.peekOverlayProps?.marker?.clear(); + this.setState({ + peekOverlayProps: undefined, + }); + }; + + debounceHandleMouseOver = debounce( + (ev) => this.handleMouseOver(ev), + PEEK_OVERLAY_DELAY, + ); + + handleMouseOver = (event: MouseEvent) => { + if ( + event.target instanceof Element && + event.target.hasAttribute(PEEKABLE_ATTRIBUTE) + ) { + const tokenElement = event.target; + const tokenElementPosition = tokenElement.getBoundingClientRect(); + const peekableAttribute = tokenElement.getAttribute(PEEKABLE_ATTRIBUTE); + if (peekableAttribute) { + // don't retrigger if hovering over the same token + if ( + this.state.peekOverlayProps?.name === peekableAttribute && + this.state.peekOverlayProps?.position.top === + tokenElementPosition.top && + this.state.peekOverlayProps?.position.left === + tokenElementPosition.left + ) { + return; + } + const paths = peekableAttribute.split("."); + if (paths.length) { + paths.splice(1, 0, "peekData"); + this.showPeekOverlay( + peekableAttribute, + tokenElement, + tokenElementPosition, + _.get(this.props.entitiesForNavigation, paths), + ); + } + } + } else { + this.hidePeekOverlay(); + } + }; + handleMouseMove = (e: React.MouseEvent) => { this.handleCustomGutter(this.editor.lineAtHeight(e.clientY, "window")); // this code only runs when we want custom tool tip for any highlighted text inside codemirror instance @@ -557,6 +663,11 @@ class CodeEditor extends Component { this.editor.off("postPick", () => this.handleAutocompleteVisibility(this.editor), ); + CodeMirror.off( + this.editor.getWrapperElement(), + "mousemove", + this.debounceHandleMouseOver, + ); // @ts-expect-error: Types are not available this.editor.closeHint(); } @@ -661,6 +772,7 @@ class CodeEditor extends Component { if (navigationData.type === ENTITY_TYPE.WIDGET) { this.props.selectWidget(navigationData.id); } + this.hidePeekOverlay(); } } }, @@ -882,6 +994,7 @@ class CodeEditor extends Component { }); this.props.startingEntityUpdate(); } + this.hidePeekOverlay(); this.handleDebouncedChange(instance, changeObj); }; @@ -1155,7 +1268,7 @@ class CodeEditor extends Component { expected={expected} hasError={isInvalid} hideEvaluatedValue={hideEvaluatedValue} - isOpen={showEvaluatedValue} + isOpen={showEvaluatedValue && !this.state.peekOverlayProps} popperPlacement={this.props.popperPlacement} popperZIndex={this.props.popperZIndex} theme={theme || EditorTheme.LIGHT} @@ -1184,6 +1297,12 @@ class CodeEditor extends Component { ref={this.editorWrapperRef} size={size} > + {this.state.peekOverlayProps && ( + this.hidePeekOverlay()} + {...this.state.peekOverlayProps} + /> + )} {this.props.leftIcon && ( {this.props.leftIcon} )} diff --git a/app/client/src/components/editorComponents/CodeEditor/markHelpers.ts b/app/client/src/components/editorComponents/CodeEditor/markHelpers.ts index 7a32680f36..ed3ecb9619 100644 --- a/app/client/src/components/editorComponents/CodeEditor/markHelpers.ts +++ b/app/client/src/components/editorComponents/CodeEditor/markHelpers.ts @@ -49,13 +49,24 @@ const hasReference = (token: CodeMirror.Token) => { return token.type === "variable" || tokenString === "this"; }; +export const PEEKABLE_CLASSNAME = "peekaboo"; +export const PEEKABLE_ATTRIBUTE = "peek-data"; +export const PEEKABLE_LINE = "peek-line"; +export const PEEKABLE_CH_START = "peek-ch-start"; +export const PEEKABLE_CH_END = "peek-ch-end"; +export const PEEK_STYLE_PERSIST_CLASS = "peek-style-persist"; + export const entityMarker: MarkHelper = ( editor: CodeMirror.Editor, entityNavigationData, ) => { editor .getAllMarks() - .filter((marker) => marker.className === NAVIGATION_CLASSNAME) + .filter( + (marker) => + marker.className === NAVIGATION_CLASSNAME || + marker.className === PEEKABLE_CLASSNAME, + ) .forEach((marker) => marker.clear()); editor.eachLine((line: CodeMirror.LineHandle) => { @@ -79,6 +90,25 @@ export const entityMarker: MarkHelper = ( title: data.name, }, ); + if (data.peekable) { + editor.markText( + { ch: token.start, line: lineNo }, + { ch: token.end, line: lineNo }, + { + className: PEEKABLE_CLASSNAME, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + attributes: { + [PEEKABLE_ATTRIBUTE]: data.name, + [PEEKABLE_CH_START]: token.start, + [PEEKABLE_CH_END]: token.end, + [PEEKABLE_LINE]: lineNo, + }, + atomic: false, + title: data.name, + }, + ); + } addMarksForChildren( entityNavigationData[tokenString], lineNo, @@ -123,6 +153,25 @@ const addMarksForChildren = ( }, ); } + if (childLink.peekable) { + editor.markText( + { ch: token.start, line: lineNo }, + { ch: token.end, line: lineNo }, + { + className: PEEKABLE_CLASSNAME, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + attributes: { + [PEEKABLE_ATTRIBUTE]: childLink.name, + [PEEKABLE_CH_START]: token.start, + [PEEKABLE_CH_END]: token.end, + [PEEKABLE_LINE]: lineNo, + }, + atomic: false, + title: childLink.name, + }, + ); + } addMarksForChildren(childNodes[token.string], lineNo, token.end, editor); } } diff --git a/app/client/src/components/editorComponents/CodeEditor/styledComponents.ts b/app/client/src/components/editorComponents/CodeEditor/styledComponents.ts index 286b1bb786..a6bc0ecfe4 100644 --- a/app/client/src/components/editorComponents/CodeEditor/styledComponents.ts +++ b/app/client/src/components/editorComponents/CodeEditor/styledComponents.ts @@ -6,6 +6,11 @@ import { } from "components/editorComponents/CodeEditor/EditorConfig"; import { Skin, Theme } from "constants/DefaultTheme"; import { Colors } from "constants/Colors"; +import { + NAVIGATION_CLASSNAME, + PEEKABLE_CLASSNAME, + PEEK_STYLE_PERSIST_CLASS, +} from "./markHelpers"; const getBorderStyle = ( props: { theme: Theme } & { @@ -163,12 +168,21 @@ export const EditorWrapper = styled.div<{ : props.theme.colors.bindingText}; font-weight: 700; } - .navigable-entity-highlight { - cursor: ${(props) => (props.ctrlPressed ? "pointer" : "selection")}; - &:hover { - text-decoration: underline; - } + + .${PEEKABLE_CLASSNAME}:hover, .${PEEK_STYLE_PERSIST_CLASS} { + background-color: #F4FFDE; } + + .${NAVIGATION_CLASSNAME} { + cursor: ${(props) => (props.ctrlPressed ? "pointer" : "selection")}; + ${(props) => + props.ctrlPressed && + `&:hover { + text-decoration: underline; + background-color: #FFEFCF; + }`} + } + .CodeMirror-matchingbracket { text-decoration: none; color: #ffd600 !important; diff --git a/app/client/src/constants/CanvasEditorConstants.tsx b/app/client/src/constants/CanvasEditorConstants.tsx index 15751d845f..e3c4ce2cc4 100644 --- a/app/client/src/constants/CanvasEditorConstants.tsx +++ b/app/client/src/constants/CanvasEditorConstants.tsx @@ -23,4 +23,5 @@ export const zIndexLayers = { PROPERTY_PANE: "z-[3]", ENTITY_EXPLORER: "z-[3]", RESIZER: "z-[4]", + PEEK_OVERLAY: "z-[10]", // to hover over the header }; diff --git a/app/client/src/selectors/navigationSelectors.ts b/app/client/src/selectors/navigationSelectors.ts index 559c6f8191..efc2b443ed 100644 --- a/app/client/src/selectors/navigationSelectors.ts +++ b/app/client/src/selectors/navigationSelectors.ts @@ -1,6 +1,6 @@ import { DataTree, - DataTreeWidget, + DataTreeAppsmith, ENTITY_TYPE, } from "entities/DataTree/dataTreeFactory"; import { createSelector } from "reselect"; @@ -13,10 +13,12 @@ import { getWidgets } from "sagas/selectors"; import { getCurrentPageId } from "selectors/editorSelectors"; import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers"; import { builderURL, jsCollectionIdURL } from "RouteBuilder"; -import { keyBy } from "lodash"; import { getDataTree } from "selectors/dataTreeSelectors"; -import { JSCollectionData } from "reducers/entityReducers/jsActionsReducer"; -import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; +import { getActionChildrenNavData } from "utils/NavigationSelector/ActionChildren"; +import { createNavData } from "utils/NavigationSelector/common"; +import { getWidgetChildrenNavData } from "utils/NavigationSelector/WidgetChildren"; +import { getJsChildrenNavData } from "utils/NavigationSelector/JsChildren"; +import { getAppsmithNavData } from "utils/NavigationSelector/AppsmithNavData"; export type NavigationData = { name: string; @@ -24,7 +26,10 @@ export type NavigationData = { type: ENTITY_TYPE; url: string | undefined; navigable: boolean; - children: Record; + children: EntityNavigationData; + peekable: boolean; + peekData?: unknown; + key?: string; }; export type EntityNavigationData = Record; @@ -43,10 +48,11 @@ export const getEntitiesForNavigation = createSelector( (plugin) => plugin.id === action.config.pluginId, ); const config = getActionConfig(action.config.pluginType); + const result = getActionChildrenNavData(action, dataTree); if (!config) return; - navigationData[action.config.name] = { - name: action.config.name, + navigationData[action.config.name] = createNavData({ id: action.config.id, + name: action.config.name, type: ENTITY_TYPE.ACTION, url: config.getURL( pageId, @@ -54,91 +60,40 @@ export const getEntitiesForNavigation = createSelector( action.config.pluginType, plugin, ), - navigable: true, - children: {}, - }; + peekable: true, + peekData: result?.peekData, + children: result?.childNavData || {}, + }); }); jsActions.forEach((jsAction) => { - navigationData[jsAction.config.name] = { - name: jsAction.config.name, + const result = getJsChildrenNavData(jsAction, pageId, dataTree); + navigationData[jsAction.config.name] = createNavData({ id: jsAction.config.id, + name: jsAction.config.name, type: ENTITY_TYPE.JSACTION, url: jsCollectionIdURL({ pageId, collectionId: jsAction.config.id }), - navigable: true, - children: getJsObjectChildren(jsAction, pageId), - }; + peekable: true, + peekData: result?.peekData, + children: result?.childNavData || {}, + }); }); Object.values(widgets).forEach((widget) => { - navigationData[widget.widgetName] = { - name: widget.widgetName, + const result = getWidgetChildrenNavData(widget, dataTree, pageId); + navigationData[widget.widgetName] = createNavData({ id: widget.widgetId, + name: widget.widgetName, type: ENTITY_TYPE.WIDGET, url: builderURL({ pageId, hash: widget.widgetId }), - navigable: true, - children: getWidgetChildren(widget, dataTree, pageId), - }; + peekable: true, + peekData: result?.peekData, + children: result?.childNavData || {}, + }); }); + navigationData["appsmith"] = getAppsmithNavData( + dataTree.appsmith as DataTreeAppsmith, + ); return navigationData; }, ); - -const getJsObjectChildren = (jsAction: JSCollectionData, pageId: string) => { - const children = [ - ...jsAction.config.actions, - ...jsAction.config.variables, - ].map((jsChild) => ({ - name: `${jsAction.config.name}.${jsChild.name}`, - key: jsChild.name, - id: `${jsAction.config.name}.${jsChild.name}`, - type: ENTITY_TYPE.JSACTION, - url: jsCollectionIdURL({ - pageId, - collectionId: jsAction.config.id, - functionName: jsChild.name, - }), - navigable: true, - children: {}, - })); - - return keyBy(children, (data) => data.key); -}; - -const getWidgetChildren = ( - widget: FlattenedWidgetProps, - dataTree: DataTree, - pageId: string, -) => { - if (widget.type === "FORM_WIDGET") { - const children: EntityNavigationData = {}; - const dataTreeWidget: DataTreeWidget = dataTree[ - widget.widgetName - ] as DataTreeWidget; - const formChildren: EntityNavigationData = {}; - if (dataTreeWidget) { - Object.keys(dataTreeWidget.data || {}).forEach((widgetName) => { - const childWidgetId = (dataTree[widgetName] as DataTreeWidget).widgetId; - formChildren[widgetName] = { - name: widgetName, - id: `${widget.widgetName}.data.${widgetName}`, - type: ENTITY_TYPE.WIDGET, - navigable: true, - children: {}, - url: builderURL({ pageId, hash: childWidgetId }), - }; - }); - } - children.data = { - name: "data", - id: `${widget.widgetName}.data`, - type: ENTITY_TYPE.WIDGET, - navigable: false, - children: formChildren, - url: undefined, - }; - - return children; - } - return {}; -}; diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index d507221f1b..4d99eb6dd1 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -279,6 +279,9 @@ export type EventName = | "BRANDING_SUBMIT_CLICK" | "Cmd+Click Navigation" | "WIDGET_PROPERTY_SEARCH" + | "PEEK_OVERLAY_OPENED" + | "PEEK_OVERLAY_COLLAPSE_EXPAND_CLICK" + | "PEEK_OVERLAY_VALUE_COPIED" | LIBRARY_EVENTS; export type LIBRARY_EVENTS = diff --git a/app/client/src/utils/NavigationSelector/ActionChildren.ts b/app/client/src/utils/NavigationSelector/ActionChildren.ts new file mode 100644 index 0000000000..506c4488a2 --- /dev/null +++ b/app/client/src/utils/NavigationSelector/ActionChildren.ts @@ -0,0 +1,51 @@ +import { entityDefinitions } from "ce/utils/autocomplete/EntityDefinitions"; +import { + DataTree, + DataTreeAction, + ENTITY_TYPE, +} from "entities/DataTree/dataTreeFactory"; +import { ActionData } from "reducers/entityReducers/actionsReducer"; +import { EntityNavigationData } from "selectors/navigationSelectors"; +import { createNavData } from "./common"; + +export const getActionChildrenNavData = ( + action: ActionData, + dataTree: DataTree, +) => { + const dataTreeAction = dataTree[action.config.name] as DataTreeAction; + if (dataTreeAction) { + const definitions = entityDefinitions.ACTION(dataTreeAction, {}); + const peekData: Record = {}; + const childNavData: EntityNavigationData = {}; + Object.keys(definitions).forEach((key) => { + if (key.indexOf("!") === -1) { + if (key === "data" || key === "isLoading" || key === "responseMeta") { + peekData[key] = dataTreeAction[key]; + childNavData[key] = createNavData({ + id: `${action.config.name}.${key}`, + name: `${action.config.name}.${key}`, + type: ENTITY_TYPE.ACTION, + url: undefined, + peekable: true, + peekData: undefined, + children: {}, + }); + } else if (key === "run" || key === "clear") { + // eslint-disable-next-line @typescript-eslint/no-empty-function + peekData[key] = function() {}; // tern inference required here + childNavData[key] = createNavData({ + id: `${action.config.name}.${key}`, + name: `${action.config.name}.${key}`, + type: ENTITY_TYPE.ACTION, + url: undefined, + peekable: true, + peekData: undefined, + children: {}, + }); + } + } + }); + + return { peekData, childNavData }; + } +}; diff --git a/app/client/src/utils/NavigationSelector/AppsmithNavData.ts b/app/client/src/utils/NavigationSelector/AppsmithNavData.ts new file mode 100644 index 0000000000..dccd88b1f0 --- /dev/null +++ b/app/client/src/utils/NavigationSelector/AppsmithNavData.ts @@ -0,0 +1,32 @@ +import { entityDefinitions } from "ce/utils/autocomplete/EntityDefinitions"; +import { + DataTreeAppsmith, + ENTITY_TYPE, +} from "entities/DataTree/dataTreeFactory"; +import { createNavData, createObjectNavData } from "./common"; + +export const getAppsmithNavData = (dataTree: DataTreeAppsmith) => { + const defs: any = entityDefinitions.APPSMITH(dataTree, {}); + + const result = createObjectNavData( + defs, + dataTree, + "appsmith", + {}, + { + // restricting peek after appsmith.store because it can contain user data + // which if large will slow down nav data generation + "appsmith.store": true, + }, + ); + + return createNavData({ + id: "appsmith", + name: "appsmith", + type: ENTITY_TYPE.APPSMITH, + url: undefined, + peekable: true, + peekData: result.peekData, + children: result.entityNavigationData, + }); +}; diff --git a/app/client/src/utils/NavigationSelector/JsChildren.ts b/app/client/src/utils/NavigationSelector/JsChildren.ts new file mode 100644 index 0000000000..7c7627d6a4 --- /dev/null +++ b/app/client/src/utils/NavigationSelector/JsChildren.ts @@ -0,0 +1,91 @@ +import { + DataTree, + DataTreeJSAction, + ENTITY_TYPE, +} from "entities/DataTree/dataTreeFactory"; +import { keyBy } from "lodash"; +import { JSCollectionData } from "reducers/entityReducers/jsActionsReducer"; +import { jsCollectionIdURL } from "RouteBuilder"; +import { + EntityNavigationData, + NavigationData, +} from "selectors/navigationSelectors"; +import { createNavData } from "./common"; + +export const getJsChildrenNavData = ( + jsAction: JSCollectionData, + pageId: string, + dataTree: DataTree, +) => { + const peekData: Record = {}; + let childNavData: EntityNavigationData = {}; + + const dataTreeAction = dataTree[jsAction.config.name] as DataTreeJSAction; + + if (dataTreeAction) { + let children: NavigationData[] = jsAction.config.actions.map((jsChild) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + peekData[jsChild.name] = function() {}; // can use new Function to parse string + + const children: EntityNavigationData = {}; + if (jsAction.data?.[jsChild.id] && jsChild.executeOnLoad) { + (peekData[jsChild.name] as any).data = jsAction.data[jsChild.id]; + children.data = createNavData({ + id: `${jsAction.config.name}.${jsChild.name}.data`, + name: `${jsAction.config.name}.${jsChild.name}.data`, + type: ENTITY_TYPE.JSACTION, + url: undefined, + peekable: true, + peekData: undefined, + children: {}, + key: jsChild.name + ".data", + }); + } + + return createNavData({ + id: `${jsAction.config.name}.${jsChild.name}`, + name: `${jsAction.config.name}.${jsChild.name}`, + type: ENTITY_TYPE.JSACTION, + url: jsCollectionIdURL({ + pageId, + collectionId: jsAction.config.id, + functionName: jsChild.name, + }), + peekable: true, + peekData: undefined, + children, + key: jsChild.name, + }); + }); + + const variableChildren: NavigationData[] = jsAction.config.variables.map( + (jsChild) => { + if (dataTreeAction) + peekData[jsChild.name] = dataTreeAction[jsChild.name]; + return createNavData({ + id: `${jsAction.config.name}.${jsChild.name}`, + name: `${jsAction.config.name}.${jsChild.name}`, + type: ENTITY_TYPE.JSACTION, + url: jsCollectionIdURL({ + pageId, + collectionId: jsAction.config.id, + functionName: jsChild.name, + }), + peekable: true, + peekData: undefined, + children: {}, + key: jsChild.name, + }); + }, + ); + + children = children.concat(variableChildren); + + childNavData = keyBy(children, (data) => data.key) as Record< + string, + NavigationData + >; + + return { childNavData, peekData }; + } +}; diff --git a/app/client/src/utils/NavigationSelector/WidgetChildren.ts b/app/client/src/utils/NavigationSelector/WidgetChildren.ts new file mode 100644 index 0000000000..4ed269e32e --- /dev/null +++ b/app/client/src/utils/NavigationSelector/WidgetChildren.ts @@ -0,0 +1,85 @@ +import { + entityDefinitions, + EntityDefinitionsOptions, +} from "ce/utils/autocomplete/EntityDefinitions"; +import { + DataTree, + DataTreeWidget, + ENTITY_TYPE, +} from "entities/DataTree/dataTreeFactory"; +import { isFunction } from "lodash"; +import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer"; +import { builderURL } from "RouteBuilder"; +import { EntityNavigationData } from "selectors/navigationSelectors"; +import { createNavData } from "./common"; + +export const getWidgetChildrenNavData = ( + widget: FlattenedWidgetProps, + dataTree: DataTree, + pageId: string, +) => { + const peekData: Record = {}; + const childNavData: EntityNavigationData = {}; + const dataTreeWidget: DataTreeWidget = dataTree[ + widget.widgetName + ] as DataTreeWidget; + if (widget.type === "FORM_WIDGET") { + const children: EntityNavigationData = {}; + const formChildren: EntityNavigationData = {}; + if (dataTreeWidget) { + Object.keys(dataTreeWidget.data || {}).forEach((widgetName) => { + const childWidgetId = (dataTree[widgetName] as DataTreeWidget).widgetId; + formChildren[widgetName] = createNavData({ + id: `${widget.widgetName}.data.${widgetName}`, + name: widgetName, + type: ENTITY_TYPE.WIDGET, + url: builderURL({ pageId, hash: childWidgetId }), + peekable: false, + peekData: undefined, + children: {}, + }); + }); + } + children.data = createNavData({ + id: `${widget.widgetName}.data`, + name: "data", + type: ENTITY_TYPE.WIDGET, + url: undefined, + peekable: false, + peekData: undefined, + children: formChildren, + }); + + return { childNavData: children, peekData }; + } + if (dataTreeWidget) { + const type: Exclude< + EntityDefinitionsOptions, + | "CANVAS_WIDGET" + | "ICON_WIDGET" + | "SKELETON_WIDGET" + | "TABS_MIGRATOR_WIDGET" + > = dataTreeWidget.type as any; + let config: any = entityDefinitions[type]; + if (config) { + if (isFunction(config)) config = config(dataTreeWidget); + const widgetProps = Object.keys(config).filter( + (k) => k.indexOf("!") === -1, + ); + widgetProps.forEach((prop) => { + const data = dataTreeWidget[prop]; + peekData[prop] = data; + childNavData[prop] = createNavData({ + id: `${widget.widgetName}.${prop}`, + name: `${widget.widgetName}.${prop}`, + type: ENTITY_TYPE.WIDGET, + url: undefined, + peekable: true, + peekData: undefined, + children: {}, + }); + }); + } + return { childNavData, peekData }; + } +}; diff --git a/app/client/src/utils/NavigationSelector/common.ts b/app/client/src/utils/NavigationSelector/common.ts new file mode 100644 index 0000000000..f1a5de8946 --- /dev/null +++ b/app/client/src/utils/NavigationSelector/common.ts @@ -0,0 +1,97 @@ +import { ENTITY_TYPE } from "entities/DataTree/types"; +import { + EntityNavigationData, + NavigationData, +} from "selectors/navigationSelectors"; + +export const createNavData = (general: { + name: string; + id: string; + type: ENTITY_TYPE; + children: EntityNavigationData; + key?: string; + url: string | undefined; + peekable: boolean; + peekData: unknown; +}): NavigationData => { + return { + name: general.name, + id: general.id, + type: general.type, + children: general.children, + key: general.key, + url: general.url, + navigable: !!general.url, + peekable: general.peekable, + peekData: general.peekData, + }; +}; + +export const isTernFunctionDef = (data: any) => + typeof data === "string" && /^fn\((?:[\w,: \(\)->])*\) -> [\w]*$/.test(data); + +export const createObjectNavData = ( + defs: any, + data: any, + parentKey: string, + peekData: any, + restrictKeysFrom: Record, +) => { + const entityNavigationData: EntityNavigationData = {}; + Object.keys(defs).forEach((key: string) => { + if (key.indexOf("!") === -1) { + const childKey = parentKey + "." + key; + if (isObject(defs[key])) { + if (Object.keys(defs[key]).length > 0 && !restrictKeysFrom[childKey]) { + peekData[key] = {}; + const result = createObjectNavData( + defs[key], + data[key], + childKey, + peekData[key], + restrictKeysFrom, + ); + peekData[key] = result.peekData; + entityNavigationData[key] = createNavData({ + id: childKey, + name: childKey, + type: ENTITY_TYPE.APPSMITH, + children: result.entityNavigationData, + url: undefined, + peekable: true, + peekData: undefined, + }); + } else { + peekData[key] = data[key]; + entityNavigationData[key] = createNavData({ + id: childKey, + name: childKey, + type: ENTITY_TYPE.APPSMITH, + children: {}, + url: undefined, + peekable: true, + peekData: undefined, + }); + } + } else { + peekData[key] = isTernFunctionDef(defs[key]) + ? // eslint-disable-next-line @typescript-eslint/no-empty-function + function() {} // tern inference required here + : data[key]; + entityNavigationData[key] = createNavData({ + id: childKey, + name: childKey, + type: ENTITY_TYPE.APPSMITH, + children: {}, + url: undefined, + peekable: true, + peekData: undefined, + }); + } + } + }); + return { peekData, entityNavigationData }; +}; + +const isObject = (data: any) => + typeof data === "object" && !Array.isArray(data) && data !== null; diff --git a/app/client/typings/react-append-to-body/index.d.ts b/app/client/typings/react-append-to-body/index.d.ts new file mode 100644 index 0000000000..3bed3a692f --- /dev/null +++ b/app/client/typings/react-append-to-body/index.d.ts @@ -0,0 +1 @@ +declare module "react-append-to-body"; diff --git a/app/client/yarn.lock b/app/client/yarn.lock index fb3a5d40aa..a11e995b49 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -12459,6 +12459,11 @@ react-app-polyfill@^3.0.0: regenerator-runtime "^0.13.9" whatwg-fetch "^3.6.2" +react-append-to-body@^2.0.26: + version "2.0.26" + resolved "https://registry.yarnpkg.com/react-append-to-body/-/react-append-to-body-2.0.26.tgz#8ada0cb404462417651bbcd172c99f8100d6470e" + integrity sha512-ZFtJH8V1kVV4LR2wrrkFJhoT+pC/MxXCWPh2FAcT4yMMTCh+QXJwkVvr3WHnAi2KvG9LvyFmjLWsvWDlLi9eeA== + react-async-script@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz"