feat: peek overlay (#20053)

## Description

Hover over appsmith properties in code to peek data.
<img width="380" alt="image"
src="https://user-images.githubusercontent.com/66776129/217707810-164924c0-36e8-4450-b087-18af333c7547.png">

This right now covers:
- Queries/JsObjects/Apis/Widgets and their properties.
- Note: For query or Api, this'll work only upto `Api.data`. (Not
`Api.data.users[0].id`)
- This is because of the way codemirror renders code and we'll need more
time to see how this is best handled.


Misc:
- added `react-append-to-body` to work with variable height for peek
overlay
- we needed a container that doesn't apply `position: absolute` to
itself
- Because, when a container's `height` is zero with `position: absolute`
(like in bp3-portal), child elements cannot be positioned using just the
`bottom` property
- with `react-append-to-body`, the container won't have `position:
absolute`, instead it is applied to the child element `<div>` directly,
hence we can position using `bottom` property.


Fixes #17507


Media
https://www.loom.com/share/0f17918fcd604805b023c215d57fce43


## Type of change
- New feature (non-breaking change which adds functionality)


## How Has This Been Tested?

- Manual

### Test Plan
https://github.com/appsmithorg/TestSmith/issues/2173
https://github.com/appsmithorg/TestSmith/issues/2178

### Issues raised during DP testing

https://github.com/appsmithorg/appsmith/pull/20053#issuecomment-1420545330

https://github.com/appsmithorg/appsmith/pull/20053#issuecomment-1424427913

## Checklist:
### Dev activity
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


### QA activity:
- [x] Test plan has been approved by relevant developers
- [ ] Test plan has been peer reviewed by QA
- [x] Cypress test cases have been added and approved by either SDET or
manual QA
- [ ] Organized project review call with relevant stakeholders after
Round 1/2 of QA
- [ ] Added Test Plan Approved label after reveiwing all Cypress test
This commit is contained in:
Anand Srinivasan 2023-02-17 21:33:34 +05:30 committed by GitHub
parent a9580339f2
commit 73ba3a39c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1119 additions and 90 deletions

View File

@ -95,6 +95,7 @@ describe("1. CommandClickNavigation", function() {
cy.get(`[${NAVIGATION_ATTRIBUTE}="Graphql_Query"]`).click({
ctrlKey: true,
force: true,
});
cy.url().should("contain", "/api/");

View File

@ -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();
});
});

View File

@ -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("[]");
});

View File

@ -174,4 +174,5 @@ export class CommonLocators {
_consoleString = ".cm-string";
_commentString = ".cm-comment";
_modalWrapper = "[data-cy='modal-wrapper']";
_editorBackButton = ".t--close-editor";
}

View File

@ -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;

View File

@ -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) {

View File

@ -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<string | number>) {
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<Record<string, any>>) {
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<string, string | number>) {
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);
}
}

View File

@ -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",

View File

@ -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");
};

View File

@ -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;
}
`;

View File

@ -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 `<div>` 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<HTMLDivElement | null> = 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 (
<div
className={`absolute ${zIndexLayers.PEEK_OVERLAY}`}
id="t--peek-overlay-container"
onMouseEnter={() => 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`,
}),
}}
>
<div
className="first-letter:uppercase"
style={{
height: "24px",
color: "var(--appsmith-color-black-700)",
padding: "4px 0px 4px 12px",
fontSize: "10px",
}}
>
{getDataTypeHeader(props.dataType)}
</div>
<MenuDivider style={{ margin: 0 }} />
<div
id="t--peek-overlay-data"
ref={dataWrapperRef}
style={{
minHeight: "20px",
padding: "2px 0px 2px 12px",
fontSize: "10px",
}}
>
{props.dataType === "object" && props.data !== null && (
<JsonWrapper
onClick={objectCollapseAnalytics}
style={{
minHeight: "20px",
maxHeight: "225px",
overflowY: "auto",
}}
>
<ReactJson src={props.data} {...reactJsonProps} />
</JsonWrapper>
)}
{props.dataType === "function" && (
<div>{(props.data as any).toString()}</div>
)}
{props.dataType === "boolean" && (
<div>{(props.data as any).toString()}</div>
)}
{props.dataType === "string" && (
<div>{(props.data as any).toString()}</div>
)}
{props.dataType === "number" && (
<div>{(props.data as any).toString()}</div>
)}
{((props.dataType !== "object" &&
props.dataType !== "function" &&
props.dataType !== "boolean" &&
props.dataType !== "string" &&
props.dataType !== "number") ||
props.data === null) && (
<div>
{(props.data as any)?.toString() ??
props.data ??
props.data === undefined
? "undefined"
: "null"}
</div>
)}
</div>
</div>
);
}

View File

@ -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<typeof mapStateToProps>;
type ReduxDispatchProps = ReturnType<typeof mapDispatchToProps>;
@ -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<Props, State> {
hinterOpen: false,
changeStarted: false,
ctrlPressed: false,
peekOverlayProps: undefined,
};
this.updatePropertyValue = this.updatePropertyValue.bind(this);
}
@ -365,6 +381,11 @@ class CodeEditor extends Component<Props, State> {
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<Props, State> {
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<HTMLDivElement, 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<Props, State> {
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<Props, State> {
if (navigationData.type === ENTITY_TYPE.WIDGET) {
this.props.selectWidget(navigationData.id);
}
this.hidePeekOverlay();
}
}
},
@ -882,6 +994,7 @@ class CodeEditor extends Component<Props, State> {
});
this.props.startingEntityUpdate();
}
this.hidePeekOverlay();
this.handleDebouncedChange(instance, changeObj);
};
@ -1155,7 +1268,7 @@ class CodeEditor extends Component<Props, State> {
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<Props, State> {
ref={this.editorWrapperRef}
size={size}
>
{this.state.peekOverlayProps && (
<PeekOverlayPopUp
hidePeekOverlay={() => this.hidePeekOverlay()}
{...this.state.peekOverlayProps}
/>
)}
{this.props.leftIcon && (
<IconContainer>{this.props.leftIcon}</IconContainer>
)}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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
};

View File

@ -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<string, NavigationData>;
children: EntityNavigationData;
peekable: boolean;
peekData?: unknown;
key?: string;
};
export type EntityNavigationData = Record<string, NavigationData>;
@ -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 {};
};

View File

@ -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 =

View File

@ -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<string, unknown> = {};
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 };
}
};

View File

@ -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,
});
};

View File

@ -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<string, unknown> = {};
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 };
}
};

View File

@ -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<string, unknown> = {};
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 };
}
};

View File

@ -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<string, boolean>,
) => {
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;

View File

@ -0,0 +1 @@
declare module "react-append-to-body";

View File

@ -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"