chore: Function Render of State Inspector (#38893)

This commit is contained in:
Hetu Nandu 2025-01-31 10:49:09 +05:30 committed by GitHub
parent 632443c013
commit 0f8e41fc7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 262 additions and 221 deletions

View File

@ -1,38 +0,0 @@
import type { MouseEventHandler } from "react";
import AnalyticsUtil from "ee/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

@ -1,17 +1,21 @@
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 React, {
type MutableRefObject,
useCallback,
useMemo,
useRef,
} from "react";
import { useEventCallback } from "usehooks-ts";
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 "@appsmith/ads";
import { useSelector } from "react-redux";
import { getConfigTree, getDataTree } from "selectors/dataTreeSelectors";
import { filterInternalProperties } from "utils/FilterInternalProperties";
import { getJSCollections } from "ee/selectors/entitiesSelector";
import * as Styled from "./styles";
import { CONTAINER_MAX_HEIGHT_PX, PEEK_OVERLAY_DELAY } from "./constants";
import { getDataTypeHeader, getPropertyData } from "./utils";
import { JSONViewer, Size } from "../../JSONViewer";
export interface PeekOverlayStateProps {
objectName: string;
@ -31,155 +35,83 @@ export const PeekOverlayPopUp = componentWillAppendToBody(
PeekOverlayPopUpContent,
);
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;
},
) {
const CONTAINER_MAX_HEIGHT_PX = 252;
const { hidePeekOverlay, objectName, position, propertyPath } = props;
const dataWrapperRef: MutableRefObject<HTMLDivElement | null> = useRef(null);
const dataTree = useSelector(getDataTree);
const configTree = useSelector(getConfigTree);
const jsActions = useSelector(getJSCollections);
const filteredData = filterInternalProperties(
props.objectName,
dataTree[props.objectName],
objectName,
dataTree[objectName],
jsActions,
dataTree,
configTree,
);
// Because getPropertyData can return a function
// And we don't want to execute it.
const [jsData] = useState(() =>
getPropertyData(filteredData, props.propertyPath),
const [jsData, dataType] = useMemo(
// Because getPropertyData can return a function
// And we don't want to execute it.
() => {
const jsData = getPropertyData(filteredData, propertyPath);
const dataType = getDataTypeHeader(jsData);
return [jsData, dataType];
},
[filteredData, propertyPath],
);
const [dataType] = useState(getDataTypeHeader(jsData));
const debouncedHide = debounce(hidePeekOverlay, PEEK_OVERLAY_DELAY);
useEffect(() => {
const wheelCallback = () => {
props.hidePeekOverlay();
const getPositionValues = useCallback(() => {
const positionValues: { $left: string; $bottom?: string; $top?: string } = {
// Always have a minimum of 8px from the left
$left: Math.max(position.right - 300, 8) + "px",
};
window.addEventListener("wheel", wheelCallback);
// if the peek overlay is going to be more than the container height, then show it from the bottom
if (position.top >= CONTAINER_MAX_HEIGHT_PX) {
positionValues.$bottom = `calc(100vh - ${position.top}px)`;
} else {
positionValues.$top = `${position.bottom}px`;
}
return () => {
window.removeEventListener("wheel", wheelCallback);
};
}, []);
return positionValues;
}, [position]);
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 getLeftPosition = (position: DOMRect) => {
let left = position.right - 300;
if (left < 0) left = 8;
return left;
};
const onWheel = useEventCallback((ev: React.WheelEvent) => {
ev.stopPropagation();
hidePeekOverlay();
});
return (
<div
<Styled.PeekOverlayContainer
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(--ads-v2-color-bg)",
boxShadow: "0px 0px 10px #0000001A", // color used from designs
borderRadius: "var(--ads-v2-border-radius)",
left: `${getLeftPosition(props.position)}px`,
...(props.position.top >= CONTAINER_MAX_HEIGHT_PX
? {
bottom: `calc(100vh - ${props.position.top}px)`,
}
: {
top: `${props.position.bottom}px`,
}),
}}
onMouseEnter={debouncedHide.cancel}
onMouseLeave={debouncedHide}
onWheel={onWheel}
{...getPositionValues()}
>
<div
className="first-letter:uppercase"
style={{
height: "24px",
color: "var(--appsmith-color-black-700)",
padding: "4px 0px 4px 12px",
fontSize: "10px",
}}
>
<Styled.DataType className="first-letter:uppercase">
{dataType}
</div>
<Divider style={{ display: "block" }} />
<div
id="t--peek-overlay-data"
ref={dataWrapperRef}
style={{
minHeight: "20px",
padding: "2px 0px 2px 12px",
fontSize: "10px",
}}
>
</Styled.DataType>
<Styled.BlockDivider />
<Styled.PeekOverlayData id="t--peek-overlay-data" ref={dataWrapperRef}>
{(dataType === "object" || dataType === "array") && jsData !== null && (
<JsonWrapper
className="as-mask"
onClick={objectCollapseAnalytics}
style={{
minHeight: "20px",
maxHeight: "225px",
overflowY: "auto",
}}
>
<ReactJson src={jsData} {...reactJsonProps} />
</JsonWrapper>
<Styled.JsonWrapper className="as-mask">
<JSONViewer size={Size.SMALL} src={jsData} />
</Styled.JsonWrapper>
)}
{/* TODO: Fix this the next time the file is edited */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{dataType === "function" && <div>{(jsData as any).toString()}</div>}
{/* TODO: Fix this the next time the file is edited */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{dataType === "boolean" && <div>{(jsData as any).toString()}</div>}
{/* TODO: Fix this the next time the file is edited */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{dataType === "string" && <div>{(jsData as any).toString()}</div>}
{/* TODO: Fix this the next time the file is edited */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{dataType === "number" && <div>{(jsData as any).toString()}</div>}
{dataType === "function" && <div>{jsData.toString()}</div>}
{dataType === "boolean" && <div>{jsData.toString()}</div>}
{dataType === "string" && <div>{jsData.toString()}</div>}
{dataType === "number" && <div>{jsData.toString()}</div>}
{((dataType !== "object" &&
dataType !== "function" &&
dataType !== "boolean" &&
@ -188,14 +120,12 @@ export function PeekOverlayPopUpContent(
dataType !== "number") ||
jsData === null) && (
<div>
{/* TODO: Fix this the next time the file is edited */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(jsData as any)?.toString() ?? jsData ?? jsData === undefined
{jsData?.toString() ?? jsData ?? jsData === undefined
? "undefined"
: "null"}
</div>
)}
</div>
</div>
</Styled.PeekOverlayData>
</Styled.PeekOverlayContainer>
);
}

View File

@ -0,0 +1,3 @@
export const CONTAINER_MAX_HEIGHT_PX = 252;
export const PEEK_OVERLAY_DELAY = 200;

View File

@ -0,0 +1,43 @@
import styled from "styled-components";
import { Divider } from "@appsmith/ads";
export const PeekOverlayContainer = styled.div<{
$left: string;
$top?: string;
$bottom?: string;
}>`
min-height: 46px;
max-height: 252px;
width: 300px;
background-color: var(--ads-v2-color-bg);
box-shadow: 0 0 10px #0000001a; // color used from designs
border-radius: var(--ads-v2-border-radius);
left: ${({ $left }) => $left};
top: ${({ $top }) => $top};
bottom: ${({ $bottom }) => $bottom};
`;
export const DataType = styled.div`
height: 24px;
color: var(--appsmith-color-black-700);
padding: var(--ads-v2-spaces-2) 0 var(--ads-v2-spaces-2)
var(--ads-v2-spaces-4);
font-size: 10px;
`;
export const BlockDivider = styled(Divider)`
display: block;
`;
export const PeekOverlayData = styled.div`
min-height: 20px;
padding: var(--ads-v2-spaces-1) 0 var(--ads-v2-spaces-1)
var(--ads-v2-spaces-4);
font-size: 10px;
`;
export const JsonWrapper = styled.div`
min-height: 20px;
max-height: 225px;
overflow-y: auto;
`;

View File

@ -0,0 +1,16 @@
import { get } from "lodash";
export const getPropertyData = (src: unknown, propertyPath: string[]) => {
return propertyPath.length > 0 ? get(src, propertyPath) : src;
};
export const getDataTypeHeader = (data: unknown) => {
const dataType = typeof data;
if (dataType === "object") {
if (Array.isArray(data)) return "array";
if (data === null) return "null";
}
return dataType;
};

View File

@ -128,10 +128,7 @@ import { getEntitiesForNavigation } from "selectors/navigationSelectors";
import history, { NavigationMethod } from "utils/history";
import { CursorPositionOrigin } from "ee/reducers/uiReducers/editorContextReducer";
import type { PeekOverlayStateProps } from "./PeekOverlayPopup/PeekOverlayPopup";
import {
PeekOverlayPopUp,
PEEK_OVERLAY_DELAY,
} from "./PeekOverlayPopup/PeekOverlayPopup";
import { PeekOverlayPopUp } from "./PeekOverlayPopup/PeekOverlayPopup";
import ConfigTreeActions from "utils/configTree";
import {
getSaveAndAutoIndentKey,
@ -164,6 +161,7 @@ import CodeMirrorTernService from "utils/autocomplete/CodemirrorTernService";
import { getEachEntityInformation } from "ee/utils/autocomplete/EntityDefinitions";
import { getCurrentPageId } from "selectors/editorSelectors";
import { executeCommandAction } from "actions/pluginActionActions";
import { PEEK_OVERLAY_DELAY } from "./PeekOverlayPopup/constants";
type ReduxStateProps = ReturnType<typeof mapStateToProps>;
type ReduxDispatchProps = ReturnType<typeof mapDispatchToProps>;
@ -202,6 +200,7 @@ export interface EditorStyleProps {
popperZIndex?: Indices;
blockCompletions?: Array<BlockCompletion>;
}
/**
* line => Line to which the gutter is added
*

View File

@ -4,6 +4,8 @@ import "@testing-library/jest-dom";
import { StateInspector } from "./StateInspector";
import { useStateInspectorItems } from "./hooks";
import { filterEntityGroupsBySearchTerm } from "IDE/utils";
import { lightTheme } from "selectors/themeSelectors";
import { ThemeProvider } from "styled-components";
jest.mock("./hooks");
jest.mock("IDE/utils");
@ -31,7 +33,11 @@ describe("StateInspector", () => {
],
{ key: "value1" },
]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
const searchInput = screen.getByPlaceholderText("Search entities");
fireEvent.change(searchInput, { target: { value: "Group 1" } });
@ -53,7 +59,11 @@ describe("StateInspector", () => {
],
{ key: "value1" },
]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
fireEvent.click(screen.getByText("Item 2"));
expect(mockOnClick).toHaveBeenCalled();
@ -71,7 +81,11 @@ describe("StateInspector", () => {
],
{ key: "Value1" },
]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
expect(
screen.getByTestId("t--selected-entity-details").textContent,
@ -84,7 +98,11 @@ describe("StateInspector", () => {
it("does not render selected item details when no item is selected", () => {
mockedUseStateInspectorItems.mockReturnValue([null, [], null]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
});
@ -101,12 +119,20 @@ describe("StateInspector", () => {
{ key: "value1" },
]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
expect(screen.getByText("Group 1")).toBeInTheDocument();
expect(screen.getByText("Group 2")).toBeInTheDocument();
});
it("renders no items when search term does not match any group", () => {
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
const searchInput = screen.getByPlaceholderText("Search entities");
fireEvent.change(searchInput, { target: { value: "Nonexistent Group" } });
@ -116,7 +142,11 @@ describe("StateInspector", () => {
it("renders no items when items list is empty", () => {
mockedUseStateInspectorItems.mockReturnValue([null, [], null]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
expect(screen.queryByText("Group 1")).not.toBeInTheDocument();
expect(screen.queryByText("Group 2")).not.toBeInTheDocument();
});
@ -130,7 +160,11 @@ describe("StateInspector", () => {
],
{},
]);
render(<StateInspector />);
render(
<ThemeProvider theme={lightTheme}>
<StateInspector />
</ThemeProvider>,
);
expect(
screen.getByTestId("t--selected-entity-details").textContent,

View File

@ -1,29 +1,21 @@
import React, { useState } from "react";
import ReactJson from "react-json-view";
import {
EntityGroupsList,
Flex,
type FlexProps,
type ListItemProps,
SearchInput,
Text,
} from "@appsmith/ads";
import { JSONViewer, Size } from "components/editorComponents/JSONViewer";
import { filterEntityGroupsBySearchTerm } from "IDE/utils";
import { useStateInspectorItems } from "./hooks";
import * as Styled from "./styles";
export const reactJsonProps = {
name: null,
enableClipboard: false,
displayDataTypes: false,
displayArrayKey: true,
quotesOnKeys: false,
style: {
fontSize: "12px",
},
collapsed: 1,
indentWidth: 2,
collapseStringsAfterLength: 30,
};
const GroupListPadding = {
pl: "spaces-3",
pr: "spaces-3",
} as FlexProps;
export const StateInspector = () => {
const [selectedItem, items, selectedItemCode] = useStateInspectorItems();
@ -51,10 +43,7 @@ export const StateInspector = () => {
/>
</Flex>
<EntityGroupsList
flexProps={{
pl: "spaces-3",
pr: "spaces-3",
}}
flexProps={GroupListPadding}
groups={filteredItemGroups.map((item) => {
return {
groupTitle: item.group,
@ -81,8 +70,8 @@ export const StateInspector = () => {
{selectedItem.icon}
<Text kind="body-m">{selectedItem.title}</Text>
</Styled.SelectedItem>
<Flex overflowY="auto" px="spaces-3">
<ReactJson src={selectedItemCode} {...reactJsonProps} />
<Flex className="as-mask" overflowY="auto" px="spaces-3">
<JSONViewer size={Size.MEDIUM} src={selectedItemCode} />
</Flex>
</Flex>
) : null}

View File

@ -0,0 +1,16 @@
import React from "react";
import type { JSONViewerProps } from "./types";
import ReactJson from "react-json-view";
import * as Styled from "./styles";
import { FontSize, IconSize, reactJsonProps } from "./constants";
export function JSONViewer(props: JSONViewerProps) {
const fontSize = FontSize[props.size];
const iconSize = IconSize[props.size];
return (
<Styled.Container $fontSize={fontSize} $iconSize={iconSize}>
<ReactJson src={props.src} {...reactJsonProps} />
</Styled.Container>
);
}

View File

@ -0,0 +1,22 @@
import { Size } from "./types";
export const FontSize = {
[Size.SMALL]: "10px",
[Size.MEDIUM]: "12px",
};
export const IconSize = {
[Size.SMALL]: "8px",
[Size.MEDIUM]: "10px",
};
export const reactJsonProps = {
name: null,
enableClipboard: false,
displayDataTypes: false,
displayArrayKey: true,
quotesOnKeys: false,
collapsed: 1,
indentWidth: 2,
collapseStringsAfterLength: 30,
};

View File

@ -0,0 +1,3 @@
export { JSONViewer } from "./JSONViewer";
export { Size } from "./types";
export type { JSONViewerProps } from "./types";

View File

@ -1,33 +1,24 @@
import styled from "styled-components";
import styled, { css } 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,
};
const ReactJSONViewerOverrider = css<{ $fontSize: string; $iconSize: string }>`
font-size: ${({ $fontSize }) => $fontSize} !important;
export const JsonWrapper = styled.div`
// all ellipsis font size
.node-ellipsis,
.function-collapsed span:nth-child(2),
.string-value span {
font-size: 10px !important;
font-size: ${({ $fontSize }) => $fontSize} !important;
}
// disable and hide first object collapser
// disable and hide first object collapse icon
.pretty-json-container
> .object-content:first-of-type
> .object-key-val:first-of-type
> span {
pointer-events: none !important;
.icon-container {
display: none !important;
}
@ -38,28 +29,42 @@ export const JsonWrapper = styled.div`
}
// collapse icon color change and alignment
.icon-container {
width: 10px !important;
height: 8px !important;
width: ${({ $iconSize }) => $iconSize} !important;
height: ${({ $iconSize }) => $iconSize} !important;
.expanded-icon {
svg {
vertical-align: middle !important;
padding-left: 0px !important;
width: 0.8em !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 {
font-size: 10px !important;
font-size: ${({ $fontSize }) => $fontSize} !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
.object-key-val {
padding-top: 0 !important;
padding-bottom: 0 !important;
@ -70,25 +75,35 @@ export const JsonWrapper = styled.div`
}
// 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;
}
`;
export const Container = styled.div<{ $fontSize: string; $iconSize: string }>`
${ReactJSONViewerOverrider}
`;

View File

@ -0,0 +1,9 @@
export enum Size {
SMALL = "small",
MEDIUM = "medium",
}
export interface JSONViewerProps {
src: unknown;
size: Size;
}