feat: peek overlay nested properties + perf improvements (#23414)

Fixes #23057
Fixes #23054

## Description
TL;DR Added support for peeking on nested properties. e.g.
`Api1.data[0].id`.

This won't work when:
-  local variables are involved in the expression. 
e.g. `Api1.data[x].id` won't support peeking at the variable `[x]` or
anything after that.
- library code is involved e.g. `moment`, `_` etc...
- when functions are called. e.g. Api1.data[0].id.toFixed()

Because these cases requires evaluation.

<img width="355" alt="image"
src="https://github.com/appsmithorg/appsmith/assets/66776129/d09d1f0d-1692-46f5-8ec1-592f4fe75f7a">

#### Media (old vs new)
https://www.loom.com/share/dedcf113439c4ee2a19028acca54045e




## Performance improvements:
- Use AST to identify expressions instead marking text manually.
- This reduces the number of markers we process (~ half).

- Before

![image](https://github.com/appsmithorg/appsmith/assets/66776129/bb16ac6b-46dd-4e39-8524-e4f4fa2c3243)

- After

![image](https://github.com/appsmithorg/appsmith/assets/66776129/28f0f209-5437-4718-a74a-f025c576afda)

- AST logs
https://www.loom.com/share/ddde93233cc8470ea04309d8a8332240

#### Type of change
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)

## Testing
>
#### How Has This Been Tested?
- [x] Manual
- [x] Jest
- [x] Cypress
>
>
#### Test Plan
https://github.com/appsmithorg/TestSmith/issues/2402

#### Issues raised during DP testing

https://github.com/appsmithorg/appsmith/pull/23414#issuecomment-1553164908

## 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] [Speedbreak
features](https://github.com/appsmithorg/TestSmith/wiki/Test-plan-implementation#speedbreaker-features-to-consider-for-every-change)
have been covered
- [x] Test plan covers all impacted features and [areas of
interest](https://github.com/appsmithorg/TestSmith/wiki/Guidelines-for-test-plans/_edit#areas-of-interest)
- [ ] Test plan has been peer reviewed by project stakeholders and other
QA members
- [x] Manually tested functionality on DP
- [ ] We had an implementation alignment call with stakeholders post QA
Round 2
- [x] Cypress test cases have been added and approved by SDET/manual QA
- [x] Added `Test Plan Approved` label after Cypress tests were reviewed
- [ ] Added `Test Plan Approved` label after JUnit tests were reviewed
This commit is contained in:
Anand Srinivasan 2023-05-26 17:12:10 +05:30 committed by GitHub
parent dcdc280750
commit 9dd015a1e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1089 additions and 523 deletions

View File

@ -8,75 +8,49 @@ describe("Peek overlay", () => {
_.apiPage.CreateAndFillApi(datasourceFormData["mockApiUrl"]);
_.apiPage.RunAPI();
_.apiPage.CreateAndFillApi(datasourceFormData["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.CreateJSObject(JsObjectContent, {
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
lineNumber: 0,
prettify: true,
});
_.jsEditor.SelectFunctionDropdown("myFun2");
_.jsEditor.RunJSObj();
_.agHelper.Sleep();
_.debuggerHelper.CloseBottomBar();
// check number array
_.peekOverlay.HoverCode("JSObject1.numArray");
_.peekOverlay.HoverCode(8, 3, "numArray");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("array");
_.peekOverlay.CheckPrimitveArrayInOverlay([1, 2, 3]);
_.peekOverlay.ResetHover();
// check basic object
_.peekOverlay.HoverCode("JSObject1.objectData");
_.peekOverlay.HoverCode(9, 3, "objectData");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("object");
_.peekOverlay.CheckBasicObjectInOverlay({ x: 123, y: "123" });
_.peekOverlay.ResetHover();
// check null - with this keyword
_.peekOverlay.HoverCode("JSObject1.nullData");
_.peekOverlay.HoverCode(10, 3, "nullData");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("null");
_.peekOverlay.CheckPrimitiveValue("null");
_.peekOverlay.ResetHover();
// check number
_.peekOverlay.HoverCode("JSObject1.numberData");
_.peekOverlay.HoverCode(11, 3, "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.HoverCode(12, 3, "isLoading");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("boolean");
_.peekOverlay.CheckPrimitiveValue("false");
@ -84,40 +58,47 @@ describe("Peek overlay", () => {
// TODO: handle this function failure on CI tests -> "function(){}"
// check function
// _.peekOverlay.HoverCode("Api1.run");
// _.peekOverlay.HoverCode(13, 3, "run");
// _.peekOverlay.IsOverlayOpen();
// _.peekOverlay.VerifyDataType("function");
// _.peekOverlay.CheckPrimitiveValue("function () {}");
// _.peekOverlay.ResetHover();
// check undefined
_.peekOverlay.HoverCode(14, 3, "data");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("undefined");
_.peekOverlay.CheckPrimitiveValue("undefined");
_.peekOverlay.ResetHover();
// check string
_.peekOverlay.HoverCode("appsmith.mode");
_.peekOverlay.HoverCode(15, 3, "mode");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("string");
_.peekOverlay.CheckPrimitiveValue("EDIT");
_.peekOverlay.ResetHover();
// check if overlay closes
_.peekOverlay.HoverCode("appsmith.store");
_.peekOverlay.HoverCode(16, 3, "store");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.ResetHover();
_.peekOverlay.IsOverlayOpen(false);
// widget object
_.peekOverlay.HoverCode("Table1");
_.peekOverlay.HoverCode(17, 1, "Table1");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("object");
_.peekOverlay.ResetHover();
// widget property
_.peekOverlay.HoverCode("Table1.pageNo");
_.peekOverlay.HoverCode(18, 3, "pageNo");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("number");
_.peekOverlay.CheckPrimitiveValue("1");
_.peekOverlay.ResetHover();
// widget property
_.peekOverlay.HoverCode("Table1.tableData");
_.peekOverlay.HoverCode(19, 3, "tableData");
_.peekOverlay.IsOverlayOpen();
_.peekOverlay.VerifyDataType("array");
_.peekOverlay.CheckObjectArrayInOverlay([{}, {}, {}]);
@ -125,3 +106,31 @@ describe("Peek overlay", () => {
});
});
});
const JsObjectContent = `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.isLoading;
Api1.run();
Api2.data;
appsmith.mode;
appsmith.store.abc;
Table1;
Table1.pageNo;
Table1.tableData;
Api1.data.users[0].id;
},
myFun2: async () => {
storeValue("abc", 123)
return Api1.run()
}
}`;

View File

@ -1,12 +1,9 @@
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",
@ -19,15 +16,18 @@ export class PeekOverlay {
};
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();
HoverCode(lineNumber: number, tokenNumber: number, verifyText: string) {
this.agHelper
.GetElement(".CodeMirror-line")
.eq(lineNumber)
.children()
.children()
.eq(tokenNumber)
.should("have.text", verifyText)
.then(($el) => {
const pos = $el[0].getBoundingClientRect();
this.HoverByPosition({ x: pos.left, y: pos.top });
});
}
IsOverlayOpen(checkIsOpen = true) {
@ -36,8 +36,15 @@ export class PeekOverlay {
: this.agHelper.AssertElementAbsence(this.locators._overlayContainer);
}
HoverByPosition(position: { x: number; y: number }) {
this.agHelper.GetElement("body").realHover({ position });
this.agHelper.Sleep();
}
ResetHover() {
this.agHelper.GetElement("body").realHover({ position: "bottomLeft" });
this.agHelper
.GetElement(".CodeMirror-code")
.realHover({ position: "bottomLeft" });
this.agHelper.Sleep();
}

View File

@ -282,10 +282,10 @@
"cypress-multi-reporters": "^1.2.4",
"cypress-network-idle": "^1.14.2",
"cypress-plugin-tab": "^1.0.5",
"cypress-real-events": "^1.7.1",
"cypress-real-events": "^1.8.1",
"cypress-tags": "^1.1.2",
"cypress-wait-until": "^1.7.2",
"cypress-xpath": "^1.4.0",
"cypress-xpath": "^1.6.0",
"diff": "^5.0.0",
"dotenv": "^8.1.0",
"eslint": "^8.36.0",

View File

@ -60,6 +60,10 @@ import {
checkIfArgumentExistAtPosition,
} from "./src/actionCreator";
// peekOverlay
import type { PeekOverlayExpressionIdentifierOptions } from "./src/peekOverlay";
import { PeekOverlayExpressionIdentifier } from "./src/peekOverlay";
// types or interfaces should be exported with type keyword, while enums can be exported like normal functions
export type {
ObjectExpression,
@ -68,6 +72,7 @@ export type {
IdentifierInfo,
TParsedJSProperty,
JSPropertyPosition,
PeekOverlayExpressionIdentifierOptions,
};
export {
@ -118,4 +123,5 @@ export {
checkIfArgumentExistAtPosition,
isJSFunctionProperty,
isFunctionPresent,
PeekOverlayExpressionIdentifier,
};

View File

@ -1,6 +1,6 @@
export const ECMA_VERSION = 11;
/* Indicates the mode the code should be parsed in.
/* Indicates the mode the code should be parsed in.
This influences global strict mode and parsing of import and export declarations.
*/
export enum SourceType {
@ -31,4 +31,6 @@ export enum NodeTypes {
BinaryExpression = "BinaryExpression",
ExpressionStatement = "ExpressionStatement",
BlockStatement = "BlockStatement",
ConditionalExpression = "ConditionalExpression",
AwaitExpression = "AwaitExpression",
}

View File

@ -118,6 +118,25 @@ export interface CallExpressionNode extends Node {
arguments: ArgumentTypes[];
}
// https://github.com/estree/estree/blob/master/es5.md#thisexpression
export interface ThisExpressionNode extends Expression {
type: "ThisExpression";
}
// https://github.com/estree/estree/blob/master/es5.md#conditionalexpression
export interface ConditionalExpressionNode extends Expression {
type: "ConditionalExpression";
test: Expression;
alternate: Expression;
consequent: Expression;
}
// https://github.com/estree/estree/blob/master/es2017.md#awaitexpression
export interface AwaitExpressionNode extends Expression {
type: "AwaitExpression";
argument: Expression;
}
export interface BlockStatementNode extends Node {
type: "BlockStatement";
body: [Node];
@ -182,6 +201,21 @@ export const isMemberExpressionNode = (
return node.type === NodeTypes.MemberExpression;
};
export const isThisExpressionNode = (
node: Node,
): node is ThisExpressionNode => {
return node.type === NodeTypes.ThisExpression;
};
export const isConditionalExpressionNode = (
node: Node,
): node is ConditionalExpressionNode =>
node.type === NodeTypes.ConditionalExpression;
export const isAwaitExpressionNode = (
node: Node,
): node is AwaitExpressionNode => node.type === NodeTypes.AwaitExpression;
export const isBinaryExpressionNode = (
node: Node,
): node is BinaryExpressionNode => {

View File

@ -0,0 +1,192 @@
import { SourceType } from "../constants/ast";
import { PeekOverlayExpressionIdentifier } from "./index";
describe("extractExpressionAtPositionWholeDoc", () => {
const scriptIdentifier = new PeekOverlayExpressionIdentifier({
sourceType: SourceType.script,
});
const jsObjectIdentifier = new PeekOverlayExpressionIdentifier({
sourceType: SourceType.module,
thisExpressionReplacement: "JsObject",
});
const checkExpressionAtScript = async (
pos: number,
resultString?: string,
) => {
let result;
try {
result = await scriptIdentifier.extractExpressionAtPosition(pos);
} catch (e) {
expect(e).toBe(
"PeekOverlayExpressionIdentifier - No expression found at position",
);
}
expect(result).toBe(resultString);
};
const checkExpressionAtJsObject = async (
pos: number,
resultString?: string,
) => {
let result;
try {
result = await jsObjectIdentifier.extractExpressionAtPosition(pos);
} catch (e) {
expect(e).toBe(
"PeekOverlayExpressionIdentifier - No expression found at position",
);
}
expect(result).toBe(resultString);
};
it("handles MemberExpressions", async () => {
// nested properties
scriptIdentifier.updateScript("Api1.data[0].id");
// at position 'A'
// 'A'pi1.data[0].id
checkExpressionAtScript(0, "Api1");
// Ap'i'1.data[0].id
checkExpressionAtScript(2, "Api1");
// Api1.'d'ata[0].id
checkExpressionAtScript(6, "Api1.data");
// Api1.data['0'].id
checkExpressionAtScript(11, "Api1.data[0]");
// Api1.data[0].i'd'
checkExpressionAtScript(14, "Api1.data[0].id");
// function call
// argument hover - Api1
scriptIdentifier.updateScript(`storeValue("abc", Api1.run)`);
checkExpressionAtScript(18, "Api1");
scriptIdentifier.updateScript("Api1.check.run()");
// Ap'i'1.check.run()
checkExpressionAtScript(2, "Api1");
// Api1.check.'r'un()
checkExpressionAtScript(12, "Api1.check.run");
// local varibles are filtered
scriptIdentifier.updateScript("Api1.check.data[x].id");
// Api1
checkExpressionAtScript(2, "Api1");
// check
checkExpressionAtScript(7, "Api1.check");
// data
checkExpressionAtScript(12, "Api1.check.data");
// x
checkExpressionAtScript(16);
// id
checkExpressionAtScript(19);
});
it("handles ExpressionStatements", async () => {
// simple statement
scriptIdentifier.updateScript("Api1");
// Ap'i'1
checkExpressionAtScript(2, "Api1");
// function call
scriptIdentifier.updateScript(`storeValue("abc", 123)`);
// storeValue("a'b'c", 123)
checkExpressionAtScript(13);
// st'o'reValue("abc", 123) - functionality not supported now
checkExpressionAtScript(2);
// consequent function calls
scriptIdentifier.updateScript(`Api1.data[0].id.toFixed().toString()`);
// toFixed
checkExpressionAtScript(16, "Api1.data[0].id.toFixed");
// toString
checkExpressionAtScript(26);
// function call argument hover
scriptIdentifier.updateScript(`storeValue("abc", Api1)`);
checkExpressionAtScript(18, "Api1");
});
it("handles BinaryExpressions", async () => {
// binary expression
scriptIdentifier.updateScript(
`Api1.data.users[0].id === "myData test" ? "Yes" : "No"`,
);
// id
checkExpressionAtScript(19, "Api1.data.users[0].id");
// myData
checkExpressionAtScript(27);
// ?
checkExpressionAtScript(40);
// Yes
checkExpressionAtScript(43);
// :
checkExpressionAtScript(48);
// No
checkExpressionAtScript(51);
// hardcoded LHS
scriptIdentifier.updateScript(`"sample" === "myData test" ? "Yes" : "No"`);
// sample
checkExpressionAtScript(1);
// nested expressions
scriptIdentifier.updateScript(
`"sample" === "myData test" ? "nested" === "nested check" ? "Yes" : "No" : "No"`,
);
// nested
checkExpressionAtScript(31);
// nested check
checkExpressionAtScript(44);
// Yes
checkExpressionAtScript(61);
// No
checkExpressionAtScript(69);
});
it("handles JsObject cases", async () => {
jsObjectIdentifier.updateScript(JsObjectWithThisKeyword);
// this keyword cases
// this
checkExpressionAtJsObject(140, "JsObject");
// numArray
checkExpressionAtJsObject(159, "JsObject.numArray");
// objectArray
checkExpressionAtJsObject(180, "JsObject.objectArray");
// [0]
checkExpressionAtJsObject(183, "JsObject.objectArray[0]");
// x
checkExpressionAtJsObject(186, "JsObject.objectArray[0].x");
// 'x'
checkExpressionAtJsObject(208, "JsObject.objectData['x']");
// 'a'
checkExpressionAtJsObject(238, "JsObject.objectData['x']['a']");
// b
checkExpressionAtJsObject(243, "JsObject.objectData['x']['a'].b");
// await keyword cases
// resetWidget
checkExpressionAtJsObject(255);
// "Switch1"
checkExpressionAtJsObject(266);
// Api1
checkExpressionAtJsObject(287, "Api1");
// run
checkExpressionAtJsObject(292, "Api1.run");
});
});
const JsObjectWithThisKeyword = `export default {
numArray: [1, 2, 3],
objectArray: [ {x: 123}, { y: "123"} ],
objectData: { x: 123, y: "123" },
myFun1: async () => {
this;
this.numArray;
this.objectArray[0].x;
this.objectData["x"];
this.objectData["x"]["a"].b;
await resetWidget("Switch1");
await Api1.run();
},
}`;

View File

@ -0,0 +1,83 @@
import type { Node } from "acorn";
import { parse } from "acorn";
import { simple } from "acorn-walk";
import type { SourceType } from "../constants/ast";
import { ECMA_VERSION } from "../constants/ast";
import { getExpressionStringAtPos, isPositionWithinNode } from "./utils";
export class PeekOverlayExpressionIdentifier {
private parsedScript?: Node;
private options: PeekOverlayExpressionIdentifierOptions;
constructor(
options: PeekOverlayExpressionIdentifierOptions,
script?: string,
) {
this.options = options;
if (script) this.updateScript(script);
}
hasParsedScript() {
return !!this.parsedScript;
}
updateScript(script: string) {
try {
this.parsedScript = parse(script, {
ecmaVersion: ECMA_VERSION,
sourceType: this.options.sourceType,
});
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
clearScript() {
this.parsedScript = undefined;
}
extractExpressionAtPosition(pos: number): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.parsedScript) {
throw "PeekOverlayExpressionIdentifier - No valid script found";
}
let nodeFound: Node | undefined;
simple(this.parsedScript, {
MemberExpression(node: Node) {
if (!nodeFound && isPositionWithinNode(node, pos)) {
nodeFound = node;
}
},
ExpressionStatement(node: Node) {
if (!nodeFound && isPositionWithinNode(node, pos)) {
nodeFound = node;
}
},
});
if (nodeFound) {
const expressionFound = getExpressionStringAtPos(
nodeFound,
pos,
this.options,
);
if (expressionFound) {
resolve(expressionFound);
} else {
reject(
"PeekOverlayExpressionIdentifier - No expression found at position",
);
}
}
reject("PeekOverlayExpressionIdentifier - No node found");
});
}
}
export type PeekOverlayExpressionIdentifierOptions = {
sourceType: SourceType;
thisExpressionReplacement?: string;
};

View File

@ -0,0 +1,184 @@
import type { Node } from "acorn";
import type {
BinaryExpressionNode,
CallExpressionNode,
ConditionalExpressionNode,
ExpressionStatement,
IdentifierNode,
MemberExpressionNode,
} from "../index";
import { isAwaitExpressionNode } from "../index";
import { isBinaryExpressionNode } from "../index";
import { isConditionalExpressionNode } from "../index";
import {
isCallExpressionNode,
isExpressionStatementNode,
isIdentifierNode,
isMemberExpressionNode,
isThisExpressionNode,
} from "../index";
import * as escodegen from "escodegen";
import { NodeTypes } from "../constants/ast";
import type { PeekOverlayExpressionIdentifierOptions } from "./index";
export const isPositionWithinNode = (node: Node, pos: number) =>
pos >= node.start && pos <= node.end;
export const getExpressionStringAtPos = (
node: Node,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
replaceThisExpression = true,
): string | undefined => {
if (!isPositionWithinNode(node, pos)) return;
if (isMemberExpressionNode(node)) {
return getExpressionAtPosFromMemberExpression(
node,
pos,
options,
replaceThisExpression,
);
} else if (isExpressionStatementNode(node)) {
return getExpressionAtPosFromExpressionStatement(node, pos, options);
} else if (isCallExpressionNode(node)) {
return getExpressionAtPosFromCallExpression(node, pos, options);
} else if (isBinaryExpressionNode(node)) {
return getExpressionAtPosFromBinaryExpression(node, pos, options);
} else if (isAwaitExpressionNode(node)) {
return getExpressionStringAtPos(node.argument, pos, options);
} else if (isConditionalExpressionNode(node)) {
return getExpressionAtPosFromConditionalExpression(node, pos, options);
} else if (isIdentifierNode(node)) {
return removeSemiColon(escodegen.generate(node));
}
};
const getExpressionAtPosFromMemberExpression = (
node: MemberExpressionNode,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
replaceThisExpression = true,
): string | undefined => {
const objectNode = node.object;
if (isLocalVariableNode(node) || isLocalVariableNode(objectNode)) return;
if (replaceThisExpression && options?.thisExpressionReplacement) {
node = replaceThisinMemberExpression(node, options);
}
// stop if objectNode is a function call -> needs evaluation
if (isCallExpressionNode(objectNode)) return;
// position is within the object node
if (pos <= objectNode.end) {
return getExpressionStringAtPos(objectNode, pos, options, false);
}
// position is within the property node
else {
const propertyNode = node.property;
if (isMemberExpressionNode(propertyNode)) {
return getExpressionAtPosFromMemberExpression(
propertyNode,
pos,
options,
false,
);
}
// generate string for the whole path
return escodegen.generate(node);
}
};
const getExpressionAtPosFromExpressionStatement = (
node: ExpressionStatement,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
): string | undefined => {
if (
isThisExpressionNode(node.expression) &&
options?.thisExpressionReplacement
) {
node.expression = thisReplacementNode(node.expression, options);
}
return getExpressionStringAtPos(node.expression, pos, options);
};
const getExpressionAtPosFromCallExpression = (
node: CallExpressionNode,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
): string | undefined => {
let selectedNode: Node | undefined;
// function call -> needs evaluation
// if (isPositionWithinNode(node.callee, pos)) {
// selectedNode = node.callee;
// }
if (node.arguments.length > 0) {
const argumentNode = node.arguments.find((node) =>
isPositionWithinNode(node, pos),
);
if (argumentNode) {
selectedNode = argumentNode;
}
}
return selectedNode && getExpressionStringAtPos(selectedNode, pos, options);
};
const getExpressionAtPosFromConditionalExpression = (
node: ConditionalExpressionNode,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
): string | undefined => {
let selectedNode: Node | undefined;
if (isPositionWithinNode(node.test, pos)) {
selectedNode = node.test;
} else if (isPositionWithinNode(node.consequent, pos)) {
selectedNode = node.consequent;
} else if (isPositionWithinNode(node.alternate, pos)) {
selectedNode = node.alternate;
}
return selectedNode && getExpressionStringAtPos(selectedNode, pos, options);
};
const getExpressionAtPosFromBinaryExpression = (
node: BinaryExpressionNode,
pos: number,
options?: PeekOverlayExpressionIdentifierOptions,
): string | undefined => {
let selectedNode: Node | undefined;
if (isPositionWithinNode(node.left, pos)) {
selectedNode = node.left;
} else if (isPositionWithinNode(node.right, pos)) {
selectedNode = node.right;
}
return selectedNode && getExpressionStringAtPos(selectedNode, pos, options);
};
export const replaceThisinMemberExpression = (
node: MemberExpressionNode,
options: PeekOverlayExpressionIdentifierOptions,
): MemberExpressionNode => {
if (isMemberExpressionNode(node.object)) {
node.object = replaceThisinMemberExpression(node.object, options);
} else if (isThisExpressionNode(node.object)) {
node.object = thisReplacementNode(node.object, options);
}
return node;
};
// replace "this" node with the provided replacement
const thisReplacementNode = (
node: Node,
options: PeekOverlayExpressionIdentifierOptions,
) => {
return {
...node,
type: NodeTypes.Identifier,
name: options.thisExpressionReplacement,
} as IdentifierNode;
};
const removeSemiColon = (value: string) =>
value.slice(-1) === ";" ? value.slice(0, value.length - 1) : value;
const isLocalVariableNode = (node: Node) =>
isMemberExpressionNode(node) &&
node.computed &&
isIdentifierNode(node.property);

View File

@ -12,13 +12,6 @@ const hasReference = (token: CodeMirror.Token) => {
return token.type === "variable" || tokenString === "this";
};
export const PEEKABLE_CLASSNAME = "peekable-entity-highlight";
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,
@ -66,11 +59,11 @@ const addMarksForLine = (
const tokenString = token.string;
if (hasReference(token) && tokenString in entityNavigationData) {
const data = entityNavigationData[tokenString];
if (data.navigable || data.peekable) {
if (data.navigable) {
editor.markText(
{ ch: token.start, line: lineNo },
{ ch: token.end, line: lineNo },
getMarkOptions(data, token, lineNo),
getMarkOptions(data),
);
}
addMarksForChildren(
@ -100,11 +93,11 @@ const addMarksForChildren = (
);
if (token.string in childNodes) {
const childLink = childNodes[token.string];
if (childLink.navigable || childLink.peekable) {
if (childLink.navigable) {
editor.markText(
{ ch: token.start, line: lineNo },
{ ch: token.end, line: lineNo },
getMarkOptions(childLink, token, lineNo),
getMarkOptions(childLink),
);
}
addMarksForChildren(childNodes[token.string], lineNo, token.end, editor);
@ -112,27 +105,15 @@ const addMarksForChildren = (
}
};
const getMarkOptions = (
data: NavigationData,
token: CodeMirror.Token,
lineNo: number,
): CodeMirror.TextMarkerOptions => {
const getMarkOptions = (data: NavigationData): CodeMirror.TextMarkerOptions => {
return {
className: `${data.navigable ? NAVIGATION_CLASSNAME : ""} ${
data.peekable ? PEEKABLE_CLASSNAME : ""
}`,
className: `${data.navigable ? NAVIGATION_CLASSNAME : ""}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
attributes: {
...(data.navigable && {
[NAVIGATE_TO_ATTRIBUTE]: `${data.name}`,
}),
...(data.peekable && {
[PEEKABLE_ATTRIBUTE]: data.name,
[PEEKABLE_CH_START]: token.start,
[PEEKABLE_CH_END]: token.end,
[PEEKABLE_LINE]: lineNo,
}),
},
atomic: false,
title: data.name,
@ -141,10 +122,6 @@ const getMarkOptions = (
const clearMarkers = (markers: CodeMirror.TextMarker[]) => {
markers.forEach((marker) => {
if (
marker.className?.includes(NAVIGATION_CLASSNAME) ||
marker.className?.includes(PEEKABLE_CLASSNAME)
)
marker.clear();
if (marker.className?.includes(NAVIGATION_CLASSNAME)) marker.clear();
});
};

View File

@ -1,19 +1,23 @@
import type { MutableRefObject } from "react";
import { useState } from "react";
import React, { useEffect, useRef } from "react";
import ReactJson from "react-json-view";
import { JsonWrapper, reactJsonProps } from "./JsonWrapper";
import { componentWillAppendToBody } from "react-append-to-body";
import { debounce } from "lodash";
import _, { debounce } from "lodash";
import { zIndexLayers } from "constants/CanvasEditorConstants";
import { objectCollapseAnalytics, textSelectAnalytics } from "./Analytics";
import { Divider } from "design-system";
import { useSelector } from "react-redux";
import { getDataTree } from "selectors/dataTreeSelectors";
import { filterInternalProperties } from "utils/FilterInternalProperties";
import { getJSCollections } from "selectors/entitiesSelector";
export type PeekOverlayStateProps = {
name: string;
objectName: string;
propertyPath: string[];
position: DOMRect;
textWidth: number;
data: unknown;
dataType: string;
};
/*
@ -29,6 +33,19 @@ export const PeekOverlayPopUp = componentWillAppendToBody(
export const PEEK_OVERLAY_DELAY = 200;
const getPropertyData = (src: unknown, propertyPath: string[]) => {
return propertyPath.length > 0 ? _.get(src, propertyPath) : src;
};
const getDataTypeHeader = (data: unknown) => {
const dataType = typeof data;
if (dataType === "object") {
if (Array.isArray(data)) return "array";
if (data === null) return "null";
}
return dataType;
};
export function PeekOverlayPopUpContent(
props: PeekOverlayStateProps & {
hidePeekOverlay: () => void;
@ -36,6 +53,23 @@ export function PeekOverlayPopUpContent(
) {
const CONTAINER_MAX_HEIGHT_PX = 252;
const dataWrapperRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const dataTree = useSelector(getDataTree);
const jsActions = useSelector(getJSCollections);
const filteredData = filterInternalProperties(
props.objectName,
dataTree[props.objectName],
jsActions,
dataTree,
);
// Because getPropertyData can return a function
// And we don't want to execute it.
const [jsData] = useState(() =>
getPropertyData(filteredData, props.propertyPath),
);
const [dataType] = useState(getDataTypeHeader(jsData));
useEffect(() => {
const wheelCallback = () => {
@ -60,14 +94,6 @@ export function PeekOverlayPopUpContent(
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}`}
@ -101,7 +127,7 @@ export function PeekOverlayPopUpContent(
fontSize: "10px",
}}
>
{getDataTypeHeader(props.dataType)}
{dataType}
</div>
<Divider style={{ display: "block" }} />
<div
@ -113,7 +139,7 @@ export function PeekOverlayPopUpContent(
fontSize: "10px",
}}
>
{props.dataType === "object" && props.data !== null && (
{(dataType === "object" || dataType === "array") && jsData !== null && (
<JsonWrapper
onClick={objectCollapseAnalytics}
style={{
@ -122,31 +148,22 @@ export function PeekOverlayPopUpContent(
overflowY: "auto",
}}
>
<ReactJson src={props.data} {...reactJsonProps} />
<ReactJson src={jsData} {...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) && (
{dataType === "function" && <div>{(jsData as any).toString()}</div>}
{dataType === "boolean" && <div>{(jsData as any).toString()}</div>}
{dataType === "string" && <div>{(jsData as any).toString()}</div>}
{dataType === "number" && <div>{(jsData as any).toString()}</div>}
{((dataType !== "object" &&
dataType !== "function" &&
dataType !== "boolean" &&
dataType !== "string" &&
dataType !== "array" &&
dataType !== "number") ||
jsData === null) && (
<div>
{(props.data as any)?.toString() ??
props.data ??
props.data === undefined
{(jsData as any)?.toString() ?? jsData ?? jsData === undefined
? "undefined"
: "null"}
</div>

View File

@ -60,16 +60,12 @@ import {
DynamicAutocompleteInputWrapper,
EditorWrapper,
IconContainer,
PEEK_STYLE_PERSIST_CLASS,
} from "components/editorComponents/CodeEditor/styledComponents";
import { bindingMarker } from "components/editorComponents/CodeEditor/MarkHelpers/bindingMarker";
import {
entityMarker,
NAVIGATE_TO_ATTRIBUTE,
PEEKABLE_ATTRIBUTE,
PEEKABLE_CH_END,
PEEKABLE_CH_START,
PEEKABLE_LINE,
PEEK_STYLE_PERSIST_CLASS,
} from "components/editorComponents/CodeEditor/MarkHelpers/entityMarker";
import {
bindingHint,
@ -153,7 +149,14 @@ import {
APPSMITH_AI,
askAIEnabled,
} from "@appsmith/components/editorComponents/GPT/trigger";
import { getAllDatasourceTableKeys } from "selectors/entitiesSelector";
import {
getAllDatasourceTableKeys,
selectInstalledLibraries,
} from "selectors/entitiesSelector";
import { debug } from "loglevel";
import { PeekOverlayExpressionIdentifier, SourceType } from "@shared/ast";
import type { MultiplexingModeConfig } from "components/editorComponents/CodeEditor/modes";
import { MULTIPLEXING_MODE_CONFIGS } from "components/editorComponents/CodeEditor/modes";
type ReduxStateProps = ReturnType<typeof mapStateToProps>;
type ReduxDispatchProps = ReturnType<typeof mapDispatchToProps>;
@ -229,6 +232,7 @@ export type EditorProps = EditorStyleProps &
isReadOnly?: boolean;
isRawView?: boolean;
isJSObject?: boolean;
jsObjectName?: string;
containerHeight?: number;
// Custom gutter
customGutter?: CodeEditorGutter;
@ -253,7 +257,7 @@ type State = {
ctrlPressed: boolean;
peekOverlayProps:
| (PeekOverlayStateProps & {
marker?: CodeMirror.TextMarker;
tokenElement: Element;
})
| undefined;
isDynamic: boolean;
@ -278,9 +282,11 @@ class CodeEditor extends Component<Props, State> {
hinters: Hinter[] = [];
annotations: Annotation[] = [];
updateLintingCallback: UpdateLintingCallback | undefined;
private peekOverlayExpressionIdentifier: PeekOverlayExpressionIdentifier;
private editorWrapperRef = React.createRef<HTMLDivElement>();
currentLineNumber: number | null = null;
AIEnabled = false;
private multiplexConfig?: MultiplexingModeConfig;
constructor(props: Props) {
super(props);
@ -296,6 +302,18 @@ class CodeEditor extends Component<Props, State> {
showAIWindow: false,
};
this.updatePropertyValue = this.updatePropertyValue.bind(this);
this.peekOverlayExpressionIdentifier = new PeekOverlayExpressionIdentifier(
props.isJSObject
? {
sourceType: SourceType.module,
thisExpressionReplacement: props.jsObjectName,
}
: {
sourceType: SourceType.script,
},
props.input.value,
);
this.multiplexConfig = MULTIPLEXING_MODE_CONFIGS[this.props.mode];
}
componentDidMount(): void {
@ -577,48 +595,40 @@ class CodeEditor extends Component<Props, State> {
};
showPeekOverlay = (
peekableAttribute: string,
expression: string,
paths: 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,
},
);
const tokenElementPosition = tokenElement.getBoundingClientRect();
if (this.state.peekOverlayProps) {
if (tokenElement === this.state.peekOverlayProps.tokenElement) return;
this.hidePeekOverlay();
}
tokenElement.classList.add(PEEK_STYLE_PERSIST_CLASS);
this.setState({
peekOverlayProps: {
name: peekableAttribute,
objectName: paths[0],
propertyPath: paths.slice(1),
position: tokenElementPosition,
tokenElement,
textWidth: tokenElementPosition.width,
marker,
data: dataToShow,
dataType: typeof dataToShow,
},
});
AnalyticsUtil.logEvent("PEEK_OVERLAY_OPENED", {
property: peekableAttribute,
property: expression,
});
};
hidePeekOverlay = () => {
this.state.peekOverlayProps?.marker?.clear();
this.setState({
peekOverlayProps: undefined,
});
if (this.state.peekOverlayProps) {
this.state.peekOverlayProps.tokenElement.classList.remove(
PEEK_STYLE_PERSIST_CLASS,
);
this.setState({
peekOverlayProps: undefined,
});
}
};
debounceHandleMouseOver = debounce(
@ -648,36 +658,109 @@ class CodeEditor extends Component<Props, State> {
setTimeout(delayedWork, 0);
};
handleMouseOver = (event: MouseEvent) => {
isPeekableElement = (element: Element) => {
if (
event.target instanceof Element &&
event.target.hasAttribute(PEEKABLE_ATTRIBUTE)
!element.classList.contains("cm-m-javascript") ||
element.classList.contains("binding-brackets")
)
return false;
if (
// global variables and functions
// JsObject1, storeValue()
element.classList.contains("cm-variable") ||
// properties and function calls
// JsObject.myFun(), Api1.data
element.classList.contains("cm-property") ||
// array indices - [0]
element.classList.contains("cm-number") ||
// string accessor - ["x"]
element.classList.contains("cm-string")
) {
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),
);
}
return true;
} else if (element.classList.contains("cm-keyword")) {
// this keyword for jsObjects
if (this.props.isJSObject && element.innerHTML === "this") {
return true;
}
}
};
getBindingSnippetAtPos = (
multiPlexConfig: MultiplexingModeConfig,
pos: number,
) => {
return multiPlexConfig.innerModes.map((innerMode) => {
const doc = this.editor.getValue();
const openPos =
doc.lastIndexOf(innerMode.open, pos) + innerMode.open.length;
const closePos = doc.indexOf(innerMode.close, pos);
return {
value: doc.slice(openPos, closePos),
offset: openPos,
};
});
};
updateScriptForPeekOverlay = (chIndex: number) => {
if (
!this.peekOverlayExpressionIdentifier.hasParsedScript() ||
this.multiplexConfig
) {
if (this.multiplexConfig) {
const bindingSnippetsByInnerMode = this.getBindingSnippetAtPos(
this.multiplexConfig,
chIndex,
);
for (const snippet of bindingSnippetsByInnerMode) {
if (snippet.value) {
this.peekOverlayExpressionIdentifier.updateScript(snippet.value);
chIndex -= snippet.offset;
break;
}
}
} else {
this.peekOverlayExpressionIdentifier.updateScript(
this.editor.getValue(),
);
}
}
return chIndex;
};
isPathLibrary = (paths: string[]) => {
return !!this.props.installedLibraries.find((installedLib) =>
installedLib.accessor.find((accessor) => accessor === paths[0]),
);
};
handleMouseOver = (event: MouseEvent) => {
const tokenElement = event.target;
if (
tokenElement instanceof Element &&
this.isPeekableElement(tokenElement)
) {
const tokenPos = this.editor.coordsChar({
left: event.clientX,
top: event.clientY,
});
const chIndex = this.updateScriptForPeekOverlay(
this.editor.indexFromPos(tokenPos),
);
this.peekOverlayExpressionIdentifier
.extractExpressionAtPosition(chIndex)
.then((lineExpression: string) => {
const paths = _.toPath(lineExpression);
if (!this.isPathLibrary(paths)) {
this.showPeekOverlay(lineExpression, paths, tokenElement);
} else {
this.hidePeekOverlay();
}
})
.catch((e) => {
this.hidePeekOverlay();
debug(e);
});
} else {
this.hidePeekOverlay();
}
@ -1086,6 +1169,8 @@ class CodeEditor extends Component<Props, State> {
changeObj.to,
);
}
this.peekOverlayExpressionIdentifier.clearScript();
};
handleDebouncedChange = _.debounce(this.handleChange, 600);
@ -1568,6 +1653,7 @@ const mapStateToProps = (state: AppState, props: EditorProps) => ({
),
featureFlags: selectFeatureFlags(state),
datasourceTableKeys: getAllDatasourceTableKeys(state, props.dataTreePath),
installedLibraries: selectInstalledLibraries(state),
});
const mapDispatchToProps = (dispatch: any) => ({

View File

@ -1,73 +1,96 @@
import CodeMirror from "codemirror";
import type { TEditorModes } from "components/editorComponents/CodeEditor/EditorConfig";
import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig";
import "codemirror/addon/mode/multiplex";
import "codemirror/mode/javascript/javascript";
import "codemirror/mode/sql/sql";
import "codemirror/addon/hint/sql-hint";
import type { TEditorSqlModes } from "./sql/config";
import { sqlModesConfig } from "./sql/config";
CodeMirror.defineMode(EditorModes.TEXT_WITH_BINDING, function (config) {
// @ts-expect-error: Types are not available
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, EditorModes.TEXT),
{
open: "{{",
close: "}}",
mode: CodeMirror.getMode(config, {
name: "javascript",
}),
},
);
});
export const BINDING_OPEN = "{{",
BINDING_CLOSE = "}}";
CodeMirror.defineMode(EditorModes.JSON_WITH_BINDING, function (config) {
// @ts-expect-error: Types are not available
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, { name: "javascript", json: true }),
{
open: "{{",
close: "}}",
mode: CodeMirror.getMode(config, {
name: "javascript",
}),
},
);
});
export interface MultiplexingModeConfig {
outerMode: string | { name: string; json?: boolean };
innerModes: {
open: string;
close: string;
}[];
}
CodeMirror.defineMode(EditorModes.GRAPHQL_WITH_BINDING, function (config) {
// @ts-expect-error: Types are not available
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, EditorModes.GRAPHQL),
{
open: "{{",
close: "}}",
mode: CodeMirror.getMode(config, {
name: "javascript",
}),
},
{
open: '"{{',
close: '}}"',
mode: CodeMirror.getMode(config, {
name: "javascript",
}),
},
);
});
export type MultiplexingModeConfigs = Record<
TEditorModes,
MultiplexingModeConfig | undefined
>;
for (const sqlModeConfig of Object.values(sqlModesConfig)) {
if (!sqlModeConfig.isMultiplex) continue;
CodeMirror.defineMode(sqlModeConfig.mode, function (config) {
export const MULTIPLEXING_MODE_CONFIGS: MultiplexingModeConfigs = {
[EditorModes.TEXT_WITH_BINDING]: {
outerMode: EditorModes.TEXT,
innerModes: [
{
open: BINDING_OPEN,
close: BINDING_CLOSE,
},
],
},
[EditorModes.JSON_WITH_BINDING]: {
outerMode: { name: "javascript", json: true },
innerModes: [
{
open: BINDING_OPEN,
close: BINDING_CLOSE,
},
],
},
[EditorModes.GRAPHQL_WITH_BINDING]: {
outerMode: EditorModes.GRAPHQL,
innerModes: [
{
open: BINDING_OPEN,
close: BINDING_CLOSE,
},
{
// https://github.com/appsmithorg/appsmith/issues/16702
open: '"{{',
close: '}}"',
},
],
},
...Object.values(sqlModesConfig)
.filter((config) => config.isMultiplex)
.reduce((prev, current) => {
prev[current.mode] = {
outerMode: current.mime,
innerModes: [
{
open: BINDING_OPEN,
close: BINDING_CLOSE,
},
],
};
return prev;
}, {} as Record<TEditorSqlModes, MultiplexingModeConfig | undefined>),
"text/plain": undefined,
"application/json": undefined,
javascript: undefined,
graphql: undefined,
};
Object.keys(MULTIPLEXING_MODE_CONFIGS).forEach((key) => {
const multiplexConfig = MULTIPLEXING_MODE_CONFIGS[key as TEditorModes];
if (!multiplexConfig) return;
CodeMirror.defineMode(key, function (config) {
// @ts-expect-error: Types are not available
return CodeMirror.multiplexingMode(
CodeMirror.getMode(config, sqlModeConfig.mime),
{
open: "{{",
close: "}}",
CodeMirror.getMode(config, multiplexConfig.outerMode),
...multiplexConfig.innerModes.map((innerMode) => ({
open: innerMode.open,
close: innerMode.close,
mode: CodeMirror.getMode(config, {
name: "javascript",
}),
},
})),
);
});
}
});

View File

@ -7,11 +7,9 @@ import {
import type { Theme } from "constants/DefaultTheme";
import { Skin } from "constants/DefaultTheme";
import { Colors } from "constants/Colors";
import {
NAVIGATION_CLASSNAME,
PEEKABLE_CLASSNAME,
PEEK_STYLE_PERSIST_CLASS,
} from "./MarkHelpers/entityMarker";
import { NAVIGATION_CLASSNAME } from "./MarkHelpers/entityMarker";
export const PEEK_STYLE_PERSIST_CLASS = "peek-style-persist";
const getBorderStyle = (
props: { theme: Theme } & {
@ -263,7 +261,7 @@ export const EditorWrapper = styled.div<{
font-weight: 700;
}
.${PEEKABLE_CLASSNAME}:hover, .${PEEK_STYLE_PERSIST_CLASS} {
.${PEEK_STYLE_PERSIST_CLASS} {
border-color: var(--ads-v2-color-border-emphasis);
background-color: #ededed;
}

View File

@ -404,6 +404,7 @@ function JSEditorForm({ jsCollection: currentJSCollection }: Props) {
onChange: handleEditorChange,
}}
isJSObject
jsObjectName={currentJSCollection.name}
mode={EditorModes.JAVASCRIPT}
placeholder="Let's write some code!"
showLightningMenu={false}

View File

@ -1,7 +1,4 @@
import type {
DataTree,
AppsmithEntity,
} from "entities/DataTree/dataTreeFactory";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { createSelector } from "reselect";
import {
@ -15,11 +12,9 @@ import { getCurrentPageId } from "selectors/editorSelectors";
import { getActionConfig } from "pages/Editor/Explorer/Actions/helpers";
import { jsCollectionIdURL, widgetURL } from "RouteBuilder";
import { getDataTree } from "selectors/dataTreeSelectors";
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";
import {
getEntityNameAndPropertyPath,
isJSAction,
@ -36,8 +31,6 @@ export type NavigationData = {
url: string | undefined;
navigable: boolean;
children: EntityNavigationData;
peekable: boolean;
peekData?: unknown;
key?: string;
pluginName?: string;
isMock?: boolean;
@ -79,8 +72,6 @@ export const getEntitiesForNavigation = createSelector(
(datasource) => datasource.id === datasourceId,
);
const config = getActionConfig(action.config.pluginType);
// dataTree used to get entityDefinitions and peekData
const result = getActionChildrenNavData(action, dataTree);
if (!config) return;
navigationData[action.config.name] = createNavData({
id: action.config.id,
@ -92,9 +83,7 @@ export const getEntitiesForNavigation = createSelector(
action.config.pluginType,
plugin,
),
peekable: true,
peekData: result?.peekData,
children: result?.childNavData || {},
children: {},
// Adding below data as it is required for analytical events
pluginName: plugin?.name,
datasourceId: datasource?.id,
@ -105,36 +94,33 @@ export const getEntitiesForNavigation = createSelector(
});
jsActions.forEach((jsAction) => {
// dataTree for null check and peekData
// dataTree for null check
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 }),
peekable: true,
peekData: result?.peekData,
children: result?.childNavData || {},
});
});
Object.values(widgets).forEach((widget) => {
// dataTree to get entityDefinitions, for url (can use getWidgetByName?) and peekData
const result = getWidgetChildrenNavData(widget, dataTree, pageId);
// dataTree to get entityDefinitions, for url (can use getWidgetByName?)
const result = getWidgetChildrenNavData(
widget.widgetName,
widget.type,
dataTree,
pageId,
);
navigationData[widget.widgetName] = createNavData({
id: widget.widgetId,
name: widget.widgetName,
type: ENTITY_TYPE.WIDGET,
url: widgetURL({ pageId, selectedWidgets: [widget.widgetId] }),
peekable: true,
peekData: result?.peekData,
children: result?.childNavData || {},
});
});
// dataTree to get entity definitions and peekData
navigationData["appsmith"] = getAppsmithNavData(
dataTree.appsmith as AppsmithEntity,
);
if (
entityName &&
isJSAction(dataTree[entityName]) &&

View File

@ -0,0 +1,26 @@
import { entityDefinitions } from "@appsmith/utils/autocomplete/EntityDefinitions";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import type { ActionEntity } from "entities/DataTree/types";
export const getActionChildrenPeekData = (
actionName: string,
dataTree: DataTree,
) => {
const dataTreeAction = dataTree[actionName] as ActionEntity;
if (dataTreeAction) {
const definitions = entityDefinitions.ACTION(dataTreeAction, {});
const peekData: Record<string, unknown> = {};
Object.keys(definitions).forEach((key) => {
if (key.indexOf("!") === -1) {
if (key === "data" || key === "isLoading" || key === "responseMeta") {
peekData[key] = dataTreeAction[key];
} else if (key === "run" || key === "clear") {
// eslint-disable-next-line @typescript-eslint/no-empty-function
peekData[key] = function () {}; // tern inference required here
}
}
});
return { peekData };
}
};

View File

@ -0,0 +1,14 @@
import { entityDefinitions } from "@appsmith/utils/autocomplete/EntityDefinitions";
import type {
AppsmithEntity,
DataTree,
} from "entities/DataTree/dataTreeFactory";
import { createObjectPeekData } from "./Common";
export const getAppsmithPeekData = (dataTree: DataTree) => {
const defs: any = entityDefinitions.APPSMITH(
dataTree.appsmith as AppsmithEntity,
{},
);
return createObjectPeekData(defs, dataTree.appsmith, {}, "appsmith");
};

View File

@ -0,0 +1,40 @@
import _ from "lodash";
export const isTernFunctionDef = (data: any) =>
typeof data === "string" && /^fn\((?:[\w,: \(\)->])*\) -> [\w]*$/.test(data);
export const createObjectPeekData = (
defs: any,
data: any,
peekData: any,
parentKey: string,
) => {
Object.keys(defs).forEach((key: string) => {
if (key.indexOf("!") === -1) {
const childKeyPathArray = [parentKey, key];
if (isObject(defs[key])) {
if (Object.keys(defs[key]).length > 0) {
peekData[key] = {};
const result = createObjectPeekData(
defs[key],
data[key],
peekData[key],
key,
);
_.set(peekData, childKeyPathArray, result.peekData);
} else {
peekData[key] = data[key];
}
} else {
peekData[key] = isTernFunctionDef(defs[key])
? // eslint-disable-next-line @typescript-eslint/no-empty-function
function () {} // tern inference required here
: data[key];
}
}
});
return { peekData };
};
const isObject = (data: any) =>
typeof data === "object" && !Array.isArray(data) && data !== null;

View File

@ -0,0 +1,29 @@
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import type { JSCollectionData } from "reducers/entityReducers/jsActionsReducer";
import type { JSActionEntity } from "entities/DataTree/types";
export const getJsActionPeekData = (
jsAction: JSCollectionData,
dataTree: DataTree,
) => {
const peekData: Record<string, unknown> = {};
const dataTreeAction = dataTree[jsAction.config.name] as JSActionEntity;
if (dataTreeAction) {
jsAction.config.actions.forEach((jsChild) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
peekData[jsChild.name] = function () {};
if (jsAction.data?.[jsChild.id] && jsChild.executeOnLoad) {
(peekData[jsChild.name] as any).data = jsAction.data[jsChild.id];
}
});
jsAction.config.variables.forEach((jsChild) => {
if (dataTreeAction) peekData[jsChild.name] = dataTreeAction[jsChild.name];
});
return { peekData };
}
};

View File

@ -0,0 +1,34 @@
import type { EntityDefinitionsOptions } from "ce/utils/autocomplete/EntityDefinitions";
import type { DataTree, WidgetEntity } from "entities/DataTree/dataTreeFactory";
import { isFunction } from "lodash";
import WidgetFactory from "utils/WidgetFactory";
export const getWidgetChildrenPeekData = (
widgetName: string,
widgetType: string,
dataTree: DataTree,
) => {
const peekData: Record<string, unknown> = {};
const dataTreeWidget: WidgetEntity = dataTree[widgetName] as WidgetEntity;
if (widgetType !== "FORM_WIDGET" && dataTreeWidget) {
const type: Exclude<
EntityDefinitionsOptions,
| "CANVAS_WIDGET"
| "ICON_WIDGET"
| "SKELETON_WIDGET"
| "TABS_MIGRATOR_WIDGET"
> = dataTreeWidget.type as any;
let config: any = WidgetFactory.getAutocompleteDefinitions(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;
});
}
}
return { peekData };
};

View File

@ -0,0 +1,42 @@
import { getActionChildrenPeekData } from "./Action";
import type {
DataTree,
DataTreeEntity,
} from "entities/DataTree/dataTreeFactory";
import type { JSCollectionDataState } from "reducers/entityReducers/jsActionsReducer";
import { getWidgetChildrenPeekData } from "./Widget";
import { getJsActionPeekData } from "./JsAction";
import { getAppsmithPeekData } from "./Appsmith";
import {
isActionEntity,
isWidgetEntity,
} from "components/editorComponents/CodeEditor/codeEditorUtils";
import {
isAppsmithEntity,
isJSAction,
} from "ce/workers/Evaluation/evaluationUtils";
export const filterInternalProperties = (
objectName: string,
dataTreeEntity: DataTreeEntity,
jsActions: JSCollectionDataState,
dataTree: DataTree,
) => {
if (!dataTreeEntity) return;
if (isActionEntity(dataTreeEntity)) {
return getActionChildrenPeekData(objectName, dataTree)?.peekData;
} else if (isAppsmithEntity(dataTreeEntity)) {
return getAppsmithPeekData(dataTree).peekData;
} else if (isJSAction(dataTreeEntity)) {
const jsAction = jsActions.find(
(jsAction) => jsAction.config.id === dataTreeEntity.actionId,
);
return jsAction
? getJsActionPeekData(jsAction, dataTree)?.peekData
: dataTreeEntity;
} else if (isWidgetEntity(dataTreeEntity)) {
return getWidgetChildrenPeekData(objectName, dataTreeEntity.type, dataTree)
?.peekData;
}
return dataTreeEntity;
};

View File

@ -1,49 +0,0 @@
import { entityDefinitions } from "@appsmith/utils/autocomplete/EntityDefinitions";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import type { ActionData } from "reducers/entityReducers/actionsReducer";
import type { EntityNavigationData } from "selectors/navigationSelectors";
import { createNavData } from "./common";
import type { ActionEntity } from "entities/DataTree/types";
export const getActionChildrenNavData = (
action: ActionData,
dataTree: DataTree,
) => {
const dataTreeAction = dataTree[action.config.name] as ActionEntity;
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

@ -1,30 +0,0 @@
import { entityDefinitions } from "@appsmith/utils/autocomplete/EntityDefinitions";
import type { AppsmithEntity } from "entities/DataTree/dataTreeFactory";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { createNavData, createObjectNavData } from "./common";
export const getAppsmithNavData = (dataTree: AppsmithEntity) => {
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

@ -15,31 +15,12 @@ export const getJsChildrenNavData = (
pageId: string,
dataTree: DataTree,
) => {
const peekData: Record<string, unknown> = {};
let childNavData: EntityNavigationData = {};
const dataTreeAction = dataTree[jsAction.config.name] as JSActionEntity;
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}`,
@ -49,17 +30,13 @@ export const getJsChildrenNavData = (
collectionId: jsAction.config.id,
functionName: jsChild.name,
}),
peekable: true,
peekData: undefined,
children,
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}`,
@ -69,8 +46,6 @@ export const getJsChildrenNavData = (
collectionId: jsAction.config.id,
functionName: jsChild.name,
}),
peekable: true,
peekData: undefined,
children: {},
key: jsChild.name,
});
@ -84,6 +59,6 @@ export const getJsChildrenNavData = (
NavigationData
>;
return { childNavData, peekData };
return { childNavData };
}
};

View File

@ -1,80 +1,33 @@
import type { EntityDefinitionsOptions } from "@appsmith/utils/autocomplete/EntityDefinitions";
import type { DataTree, WidgetEntity } from "entities/DataTree/dataTreeFactory";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import { isFunction } from "lodash";
import type { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer";
import { builderURL } from "RouteBuilder";
import type { EntityNavigationData } from "selectors/navigationSelectors";
import { createNavData } from "./common";
import WidgetFactory from "utils/WidgetFactory";
export const getWidgetChildrenNavData = (
widget: FlattenedWidgetProps,
widgetName: string,
widgetType: string,
dataTree: DataTree,
pageId: string,
) => {
const peekData: Record<string, unknown> = {};
const childNavData: EntityNavigationData = {};
const dataTreeWidget: WidgetEntity = dataTree[
widget.widgetName
] as WidgetEntity;
if (widget.type === "FORM_WIDGET") {
const dataTreeWidget: WidgetEntity = dataTree[widgetName] as WidgetEntity;
if (widgetType === "FORM_WIDGET") {
const children: EntityNavigationData = {};
const formChildren: EntityNavigationData = {};
if (dataTreeWidget) {
Object.keys(dataTreeWidget.data || {}).forEach((widgetName) => {
const childWidgetId = (dataTree[widgetName] as WidgetEntity).widgetId;
formChildren[widgetName] = createNavData({
id: `${widget.widgetName}.data.${widgetName}`,
name: widgetName,
Object.keys(dataTreeWidget.data || {}).forEach((childWidgetName) => {
const childWidgetId = (dataTree[childWidgetName] as WidgetEntity)
.widgetId;
formChildren[childWidgetName] = createNavData({
id: `${widgetName}.data.${childWidgetName}`,
name: childWidgetName,
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 = WidgetFactory.getAutocompleteDefinitions(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 };
return { childNavData: children };
}
};

View File

@ -1,4 +1,4 @@
import { ENTITY_TYPE } from "entities/DataTree/types";
import type { ENTITY_TYPE } from "entities/DataTree/types";
import type {
EntityNavigationData,
NavigationData,
@ -11,8 +11,6 @@ export const createNavData = (general: {
children: EntityNavigationData;
key?: string;
url: string | undefined;
peekable: boolean;
peekData: unknown;
pluginName?: string;
datasourceId?: string;
isMock?: boolean;
@ -26,80 +24,9 @@ export const createNavData = (general: {
key: general.key,
url: general.url,
navigable: !!general.url,
peekable: general.peekable,
peekData: general.peekData,
pluginName: general.pluginName,
datasourceId: general.datasourceId,
isMock: general.isMock,
actionType: general.actionType,
};
};
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

@ -9576,10 +9576,10 @@ __metadata:
cypress-multi-reporters: ^1.2.4
cypress-network-idle: ^1.14.2
cypress-plugin-tab: ^1.0.5
cypress-real-events: ^1.7.1
cypress-real-events: ^1.8.1
cypress-tags: ^1.1.2
cypress-wait-until: ^1.7.2
cypress-xpath: ^1.4.0
cypress-xpath: ^1.6.0
dayjs: ^1.10.6
deep-diff: ^1.0.2
design-system: "npm:@appsmithorg/design-system@2.1.9"
@ -13203,12 +13203,12 @@ __metadata:
languageName: node
linkType: hard
"cypress-real-events@npm:^1.7.1":
version: 1.7.1
resolution: "cypress-real-events@npm:1.7.1"
"cypress-real-events@npm:^1.8.1":
version: 1.8.1
resolution: "cypress-real-events@npm:1.8.1"
peerDependencies:
cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x
checksum: b31c2facfa03e01e298926cd0925260b12474770fc1a3ce8998da21818db7e6d9fc2f9eb60d1771aa4ce3c29aca63d04da21e1a63e3bb117b1506a72ab0c3eb1
cypress: ^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x
checksum: 48f229335f26f8c225428563e94ca091ab7a00e943a4c953e062135b1d37672edec9cb7667452d894b43528b52924cd1b6061df044ccb0665b9c0965a0fe214f
languageName: node
linkType: hard
@ -13232,10 +13232,10 @@ __metadata:
languageName: node
linkType: hard
"cypress-xpath@npm:^1.4.0":
version: 1.6.0
resolution: "cypress-xpath@npm:1.6.0"
checksum: 67cc5613d70a090b1a162d31d0ef11831576592c87ca07d242e2c07a6df2fca413fc81ea7df148a0d0c888025bb7ec0dad34684f2a322eeaa2863b8f3b65dd32
"cypress-xpath@npm:^1.6.0":
version: 1.8.0
resolution: "cypress-xpath@npm:1.8.0"
checksum: 11792ec46b898f2e00ced81dedcdca8e30d5c232778780210423d1ec0b07cd8b2a29a475d38eadaf9fd7e962efec7f5cee17f92884c308636cfa506efe6017e6
languageName: node
linkType: hard