chore: Update Entity Tree implementation for better composition (#39488)

This commit is contained in:
Hetu Nandu 2025-03-03 07:01:10 +05:30 committed by GitHub
parent 213ebf963b
commit 3f2972e27c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 220 additions and 154 deletions

View File

@ -9,6 +9,7 @@ import type {
} from "./EntityListTree.types";
import { ExplorerContainer } from "../ExplorerContainer";
import { Flex, Icon } from "../../..";
import { EntityItem } from "../EntityItem";
import { noop } from "lodash";
const meta: Meta<typeof EntityListTree> = {
@ -18,7 +19,6 @@ const meta: Meta<typeof EntityListTree> = {
export default meta;
const onClick = noop;
const nameEditorConfig = {
canEdit: true,
isEditing: false,
@ -28,60 +28,50 @@ const nameEditorConfig = {
validateName: () => null,
};
const names = {
"1": "Parent 1",
"1.1": "Child 1.1",
"1.1.1": "Child 1.1.1",
"1.1.2": "Child 1.1.2",
"1.2": "Child 1.2",
"2": "Parent 2",
};
const Tree: EntityListTreeProps["items"] = [
{
startIcon: <Icon name="apps-line" />,
id: "1",
title: "Parent 1",
isExpanded: true,
onClick,
nameEditorConfig,
isSelected: false,
children: [
{
startIcon: <Icon name="apps-line" />,
id: "1.1",
title: "Child 1",
isExpanded: false,
isSelected: true,
onClick,
nameEditorConfig,
children: [
{
startIcon: <Icon name="apps-line" />,
id: "1.1.1",
title: "Grandchild 1",
isExpanded: false,
onClick,
nameEditorConfig,
isSelected: false,
},
{
startIcon: <Icon name="apps-line" />,
id: "1.1.2",
isDisabled: true,
title: "Grandchild 2",
isExpanded: false,
onClick,
nameEditorConfig,
isSelected: false,
},
],
},
{
startIcon: <Icon name="apps-line" />,
id: "1.2",
title: "Child 2",
isExpanded: false,
onClick,
nameEditorConfig,
isSelected: false,
},
],
},
{
startIcon: <Icon name="apps-line" />,
id: "2",
title: "Parent 2",
isExpanded: false,
onClick,
nameEditorConfig,
isSelected: false,
},
];
@ -97,28 +87,9 @@ const treeUpdate = (
});
};
const Template = (props: { outsideSelection: string }) => {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const [selected, setSelected] = React.useState<string | null>(
props.outsideSelection,
);
const EntityItemComponent = (props: { item: EntityListTreeItem }) => {
const { item } = props;
const [editing, setEditing] = React.useState<string | null>(null);
useEffect(
function handleSyncOfSelection() {
setSelected(props.outsideSelection);
},
[props.outsideSelection],
);
const onExpandClick = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !Boolean(prev[id]) }));
};
const onItemSelect = (id: string) => {
setSelected(id);
};
const onItemEdit = (id: string) => {
setEditing(id);
};
@ -127,27 +98,56 @@ const Template = (props: { outsideSelection: string }) => {
setEditing(null);
};
return (
<EntityItem
{...item}
nameEditorConfig={{
...nameEditorConfig,
isEditing: item.id === editing,
onEditComplete: completeEdit,
onNameSave: noop,
validateName: () => null,
}}
onClick={noop}
onDoubleClick={() => onItemEdit(item.id)}
startIcon={<Icon name="apps-line" />}
title={names[item.id as keyof typeof names] || item.id}
/>
);
};
const Template = (props: { selectedItem: string }) => {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const [selected, setSelected] = React.useState<string | null>(
props.selectedItem,
);
useEffect(
function handleSyncOfSelection() {
setSelected(props.selectedItem);
},
[props.selectedItem],
);
const onExpandClick = (id: string) => {
setExpanded((prev) => ({ ...prev, [id]: !Boolean(prev[id]) }));
};
const updatedTree = treeUpdate(Tree, (item) => ({
...item,
isExpanded: Boolean(expanded[item.id]),
isSelected: item.id === selected,
onClick: () => onItemSelect(item.id),
onDoubleClick: () => onItemEdit(item.id),
nameEditorConfig: {
canEdit: true,
isEditing: item.id === editing,
isLoading: false,
onEditComplete: completeEdit,
onNameSave: noop,
validateName: () => null,
},
}));
return (
<Flex bg="white" overflow="hidden" width="400px">
<ExplorerContainer borderRight="STANDARD" height="500px" width="255px">
<Flex flexDirection="column" gap="spaces-2" p="spaces-3">
<EntityListTree items={updatedTree} onItemExpand={onExpandClick} />
<EntityListTree
ItemComponent={EntityItemComponent}
items={updatedTree}
onItemExpand={onExpandClick}
/>
</Flex>
</ExplorerContainer>
</Flex>
@ -157,5 +157,5 @@ const Template = (props: { outsideSelection: string }) => {
export const Basic = Template.bind({}) as StoryObj;
Basic.args = {
outsideSelection: "1",
selectedItem: "1",
};

View File

@ -1,39 +1,41 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { EntityListTree } from "./EntityListTree";
import type { EntityListTreeProps } from "./EntityListTree.types";
import type {
EntityListTreeItem,
EntityListTreeProps,
} from "./EntityListTree.types";
const mockOnItemExpand = jest.fn();
const mockNameEditorConfig = {
canEdit: true,
isEditing: false,
isLoading: false,
onEditComplete: jest.fn(),
onNameSave: jest.fn(),
validateName: jest.fn(),
const name = {
"1": "Parent 1",
"1.1": "Child 1.1",
"1.1.1": "Child 1.1.1",
"1.1.2": "Child 1.1.2",
"1.2": "Child 1.2",
"2": "No Children Parent",
"1-1": "Child",
};
const mockOnClick = jest.fn();
const ItemComponent = ({ item }: { item: EntityListTreeItem }) => {
return <div>{name[item.id as keyof typeof name] || item.id}</div>;
};
const defaultProps: EntityListTreeProps = {
ItemComponent,
items: [
{
id: "1",
title: "Parent",
isExpanded: false,
isSelected: false,
isDisabled: false,
nameEditorConfig: mockNameEditorConfig,
onClick: mockOnClick,
children: [
{
id: "1-1",
title: "Child",
isExpanded: false,
isSelected: false,
isDisabled: false,
nameEditorConfig: mockNameEditorConfig,
onClick: mockOnClick,
children: [],
},
],
@ -50,7 +52,7 @@ describe("EntityListTree", () => {
it("calls onItemExpand when expand icon is clicked", () => {
render(<EntityListTree {...defaultProps} />);
const expandIcon = screen.getByTestId("entity-item-expand-icon");
const expandIcon = screen.getByTestId("t--entity-item-expand-icon");
fireEvent.click(expandIcon);
expect(mockOnItemExpand).toHaveBeenCalledWith("1");
@ -62,19 +64,16 @@ describe("EntityListTree", () => {
items: [
{
id: "2",
title: "No Children Parent",
isExpanded: false,
isSelected: false,
isDisabled: false,
children: [],
nameEditorConfig: mockNameEditorConfig,
onClick: mockOnClick,
},
],
};
render(<EntityListTree {...props} />);
const expandIcon = screen.queryByTestId("entity-item-expand-icon");
const expandIcon = screen.queryByTestId("t--entity-item-expand-icon");
expect(
screen.getByRole("treeitem", { name: "No Children Parent" }),
@ -88,21 +87,15 @@ describe("EntityListTree", () => {
items: [
{
id: "1",
title: "Parent",
isExpanded: true,
isSelected: false,
isDisabled: false,
nameEditorConfig: mockNameEditorConfig,
onClick: mockOnClick,
children: [
{
id: "1-1",
title: "Child",
isExpanded: false,
isSelected: false,
isDisabled: false,
nameEditorConfig: mockNameEditorConfig,
onClick: mockOnClick,
children: [],
},
],

View File

@ -2,7 +2,6 @@ import React, { useCallback } from "react";
import type { EntityListTreeProps } from "./EntityListTree.types";
import { Flex } from "../../../Flex";
import { Icon } from "../../../Icon";
import { EntityItem } from "../EntityItem";
import {
CollapseSpacer,
PaddingOverrider,
@ -11,7 +10,7 @@ import {
} from "./EntityListTree.styles";
export function EntityListTree(props: EntityListTreeProps) {
const { onItemExpand } = props;
const { ItemComponent, onItemExpand } = props;
const handleOnExpandClick = useCallback(
(event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
@ -51,7 +50,7 @@ export function EntityListTree(props: EntityListTreeProps) {
{item.children && item.children.length ? (
<CollapseWrapper
data-itemid={item.id}
data-testid="entity-item-expand-icon"
data-testid="t--entity-item-expand-icon"
onClick={handleOnExpandClick}
>
<Icon
@ -65,11 +64,12 @@ export function EntityListTree(props: EntityListTreeProps) {
<CollapseSpacer />
)}
<PaddingOverrider>
<EntityItem {...item} />
<ItemComponent item={item} />
</PaddingOverrider>
</EntityItemWrapper>
{item.children && item.isExpanded ? (
<EntityListTree
ItemComponent={ItemComponent}
depth={childrenDepth}
items={item.children}
onItemExpand={onItemExpand}

View File

@ -1,12 +1,14 @@
import type { EntityItemProps } from "../EntityItem/EntityItem.types";
export interface EntityListTreeItem extends EntityItemProps {
export interface EntityListTreeItem {
children?: EntityListTreeItem[];
isExpanded: boolean;
isSelected: boolean;
isDisabled?: boolean;
id: string;
}
export interface EntityListTreeProps {
depth?: number;
items: EntityListTreeItem[];
ItemComponent: React.ComponentType<{ item: EntityListTreeItem }>;
onItemExpand: (id: string) => void;
}

View File

@ -10,7 +10,8 @@ import { getUsedActionNames } from "selectors/actionSelectors";
import { isNameValid } from "utils/helpers";
interface UseValidateEntityNameProps {
entityName?: string;
entityName: string;
entityId?: string;
nameErrorMessage?: (name: string) => string;
}
@ -18,18 +19,22 @@ interface UseValidateEntityNameProps {
* Provides a unified way to validate entity names.
*/
export function useValidateEntityName(props: UseValidateEntityNameProps) {
const { entityName, nameErrorMessage = ACTION_NAME_CONFLICT_ERROR } = props;
const {
entityId = "",
entityName,
nameErrorMessage = ACTION_NAME_CONFLICT_ERROR,
} = props;
const usedEntityNames = useSelector(
(state: AppState) => getUsedActionNames(state, ""),
(state: AppState) => getUsedActionNames(state, entityId),
shallowEqual,
);
return useCallback(
(name: string, oldName: string | undefined = entityName): string | null => {
(name: string): string | null => {
if (!name || name.trim().length === 0) {
return createMessage(ACTION_INVALID_NAME_ERROR);
} else if (name !== oldName && !isNameValid(name, usedEntityNames)) {
} else if (name !== entityName && !isNameValid(name, usedEntityNames)) {
return createMessage(nameErrorMessage, name);
}

View File

@ -83,7 +83,7 @@ export const JSEntityItem = ({ item }: { item: EntityItemProps }) => {
onEditComplete: exitEditMode,
onNameSave: (newName: string) =>
dispatch(saveJSObjectNameBasedOnIdeType(jsAction.id, newName, ideType)),
validateName: (newName: string) => validateName(newName, item.title),
validateName: (newName: string) => validateName(newName),
};
}, [
canManageJSAction,

View File

@ -99,7 +99,7 @@ export const QueryEntityItem = ({ item }: { item: EntityItemProps }) => {
onEditComplete: exitEditMode,
onNameSave: (newName: string) =>
dispatch(saveActionNameBasedOnIdeType(action.id, newName, ideType)),
validateName: (newName: string) => validateName(newName, item.title),
validateName: (newName: string) => validateName(newName),
};
}, [
canManageAction,

View File

@ -1,73 +1,29 @@
import React, { useCallback } from "react";
import React from "react";
import { EntityListTree } from "@appsmith/ads";
import { useDispatch, useSelector } from "react-redux";
import { useSelector } from "react-redux";
import { selectWidgetsForCurrentPage } from "ee/selectors/entitiesSelector";
import { getSelectedWidgets } from "selectors/ui";
import { getPagePermissions } from "selectors/editorSelectors";
import { getHasManagePagePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { useValidateEntityName } from "IDE";
import { updateWidgetName } from "actions/propertyPaneActions";
import { WidgetContextMenu } from "./WidgetContextMenu";
import { useSwitchToWidget } from "./hooks/useSwitchToWidget";
import { WidgetTypeIcon } from "./WidgetTypeIcon";
import { useWidgetTreeState } from "./hooks/useWidgetTreeExpandedState";
import { enhanceItemsTree } from "./utils/enhanceTree";
import { useNameEditorState } from "IDE/hooks/useNameEditorState";
import { WidgetTreeItem } from "./WidgetTreeItem";
export const UIEntityListTree = () => {
const widgets = useSelector(selectWidgetsForCurrentPage);
const selectedWidgets = useSelector(getSelectedWidgets);
const switchToWidget = useSwitchToWidget();
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const pagePermissions = useSelector(getPagePermissions);
const canManagePages = getHasManagePagePermission(
isFeatureEnabled,
pagePermissions,
);
const dispatch = useDispatch();
const handleNameSave = useCallback(
(id: string, newName: string) => {
dispatch(updateWidgetName(id, newName));
},
[dispatch],
);
const { editingEntity, enterEditMode, exitEditMode, updatingEntity } =
useNameEditorState();
const validateName = useValidateEntityName({});
const { expandedWidgets, handleExpand } = useWidgetTreeState();
const items = enhanceItemsTree(widgets?.children || [], (widget) => ({
id: widget.widgetId,
title: widget.widgetName,
startIcon: <WidgetTypeIcon type={widget.type} />,
isSelected: selectedWidgets.includes(widget.widgetId),
isExpanded: expandedWidgets.includes(widget.widgetId),
onClick: (e) => switchToWidget(e, widget),
onDoubleClick: () => enterEditMode(widget.widgetId),
rightControl: (
<WidgetContextMenu
canManagePages={canManagePages}
widgetId={widget.widgetId}
/>
),
rightControlVisibility: "hover",
nameEditorConfig: {
canEdit: canManagePages,
isLoading: updatingEntity === widget.widgetId,
isEditing: editingEntity === widget.widgetId,
onNameSave: (newName) => handleNameSave(widget.widgetId, newName),
onEditComplete: exitEditMode,
validateName: (newName) => validateName(newName, widget.widgetName),
},
}));
return <EntityListTree items={items} onItemExpand={handleExpand} />;
return (
<EntityListTree
ItemComponent={WidgetTreeItem}
items={items}
onItemExpand={handleExpand}
/>
);
};

View File

@ -0,0 +1,107 @@
import React, { useCallback, useMemo } from "react";
import { type EntityListTreeItem, EntityItem } from "@appsmith/ads";
import { WidgetContextMenu } from "./WidgetContextMenu";
import { useDispatch, useSelector } from "react-redux";
import { getWidgetByID } from "sagas/selectors";
import { updateWidgetName } from "actions/propertyPaneActions";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getHasManagePagePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
import { useValidateEntityName } from "IDE";
import { useNameEditorState } from "IDE/hooks/useNameEditorState";
import { getPagePermissions } from "selectors/editorSelectors";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { useSwitchToWidget } from "./hooks/useSwitchToWidget";
import { WidgetTypeIcon } from "./WidgetTypeIcon";
export const WidgetTreeItem = ({ item }: { item: EntityListTreeItem }) => {
const widget = useSelector(getWidgetByID(item.id));
const switchToWidget = useSwitchToWidget();
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
const pagePermissions = useSelector(getPagePermissions);
const canManagePages = getHasManagePagePermission(
isFeatureEnabled,
pagePermissions,
);
const dispatch = useDispatch();
const handleNameSave = useCallback(
(id: string, newName: string) => {
dispatch(updateWidgetName(id, newName));
},
[dispatch],
);
const { editingEntity, enterEditMode, exitEditMode, updatingEntity } =
useNameEditorState();
const validateName = useValidateEntityName({
entityName: widget.widgetName,
entityId: widget.widgetId,
});
const isLoading = updatingEntity === widget.widgetId;
const isEditing = editingEntity === widget.widgetId;
const onNameSave = useCallback(
(newName: string) => handleNameSave(widget.widgetId, newName),
[handleNameSave, widget.widgetId],
);
const nameEditorConfig = useMemo(
() => ({
canEdit: canManagePages,
isLoading,
isEditing,
onNameSave,
onEditComplete: exitEditMode,
validateName,
}),
[
canManagePages,
exitEditMode,
isEditing,
isLoading,
onNameSave,
validateName,
],
);
const startIcon = useMemo(
() => <WidgetTypeIcon type={widget.type} />,
[widget.type],
);
const onClick = useCallback(
(e: React.MouseEvent) => switchToWidget(e, widget),
[switchToWidget, widget],
);
const onDoubleClick = useCallback(
() => enterEditMode(widget.widgetId),
[enterEditMode, widget.widgetId],
);
const rightControl = useMemo(
() => (
<WidgetContextMenu
canManagePages={canManagePages}
widgetId={widget.widgetId}
/>
),
[canManagePages, widget.widgetId],
);
return (
<EntityItem
id={item.id}
isSelected={item.isSelected}
nameEditorConfig={nameEditorConfig}
onClick={onClick}
onDoubleClick={onDoubleClick}
rightControl={rightControl}
rightControlVisibility="hover"
startIcon={startIcon}
title={widget.widgetName}
/>
);
};

View File

@ -1,5 +1,4 @@
import { useCallback, type MouseEvent } from "react";
import type { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer";
import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { builderURL } from "ee/RouteBuilder";
import { NavigationMethod } from "utils/history";
@ -7,6 +6,7 @@ import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/useNavigateTo
import { useSelector } from "react-redux";
import { getCurrentBasePageId } from "selectors/editorSelectors";
import { getSelectedWidgets } from "selectors/ui";
import type { WidgetType } from "constants/WidgetConstants";
export function useSwitchToWidget() {
const { navigateToWidget } = useNavigateToWidget();
@ -14,7 +14,10 @@ export function useSwitchToWidget() {
const selectedWidgets = useSelector(getSelectedWidgets);
return useCallback(
(e: MouseEvent, widget: CanvasStructure) => {
(
e: MouseEvent,
widget: { widgetId: string; widgetName: string; type: WidgetType },
) => {
const isMultiSelect = e.metaKey || e.ctrlKey;
const isShiftSelect = e.shiftKey;