feat: ADS Entity List Tree (#38493)

Co-authored-by: Ankita Kinger <ankita@appsmith.com>
This commit is contained in:
Hetu Nandu 2025-01-13 13:54:30 +05:30 committed by GitHub
parent d52a0f283a
commit f8784fcf6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 937 additions and 103 deletions

View File

@ -1,5 +1,8 @@
import React from "react";
import { Button, Flex, Icon, Text } from "../../..";
import { Button } from "../../../Button";
import { Flex } from "../../../Flex";
import { Icon } from "../../../Icon";
import { Text } from "../../../Text";
import type { EmptyStateProps } from "./EmptyState.types";
const EmptyState = ({ button, description, icon }: EmptyStateProps) => {

View File

@ -1,4 +1,5 @@
import { type IconNames, type ButtonKind } from "../../..";
import type { IconNames } from "../../../Icon";
import type { ButtonKind } from "../../../Button";
export interface EmptyStateProps {
icon: IconNames;

View File

@ -1,5 +1,5 @@
import styled from "styled-components";
import { Text } from "../../..";
import { Text } from "../../../Text";
export const EntityEditableName = styled(Text)`
overflow: hidden;

View File

@ -1,5 +1,7 @@
import React, { useMemo } from "react";
import { ListItem, Spinner, Tooltip } from "../../..";
import { ListItem } from "../../../List";
import { Spinner } from "../../../Spinner";
import { Tooltip } from "../../../Tooltip";
import type { EntityItemProps } from "./EntityItem.types";
import { EntityEditableName } from "./EntityItem.styles";
@ -32,11 +34,21 @@ export const EntityItem = (props: EntityItemProps) => {
onNameSave,
);
// When in loading state, start icon becomes the loading icon
const startIcon = useMemo(() => {
if (isLoading) {
return <Spinner size="md" />;
}
return props.startIcon;
}, [isLoading, props.startIcon]);
const inputProps = useMemo(
() => ({
onChange: handleTitleChange,
onKeyUp: handleKeyUp,
style: {
backgroundColor: "var(--ads-v2-color-bg)",
paddingTop: 0,
paddingBottom: 0,
height: "32px",
@ -46,14 +58,7 @@ export const EntityItem = (props: EntityItemProps) => {
[handleKeyUp, handleTitleChange],
);
const startIcon = useMemo(() => {
if (isLoading) {
return <Spinner size="md" />;
}
return props.startIcon;
}, [isLoading, props.startIcon]);
// Use List Item custom title prop to show the editable name
const customTitle = useMemo(() => {
return (
<Tooltip
@ -75,6 +80,15 @@ export const EntityItem = (props: EntityItemProps) => {
);
}, [editableName, inputProps, inputRef, inEditMode, validationError]);
// Do not show right control if the visibility is hover and the item is in edit mode
const rightControl = useMemo(() => {
if (props.rightControlVisibility === "hover" && inEditMode) {
return null;
}
return props.rightControl;
}, [inEditMode, props.rightControl, props.rightControlVisibility]);
return (
<ListItem
{...props}
@ -82,6 +96,7 @@ export const EntityItem = (props: EntityItemProps) => {
customTitleComponent={customTitle}
data-testid={`t--entity-item-${props.title}`}
id={"entity-" + props.id}
rightControl={rightControl}
startIcon={startIcon}
/>
);

View File

@ -0,0 +1,161 @@
/* eslint-disable no-console */
import React, { useEffect } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { EntityListTree } from "./EntityListTree";
import type {
EntityListTreeItem,
EntityListTreeProps,
} from "./EntityListTree.types";
import { ExplorerContainer } from "../ExplorerContainer";
import { Flex, Icon } from "../../..";
import { noop } from "lodash";
const meta: Meta<typeof EntityListTree> = {
title: "ADS/Templates/Entity Explorer/Entity List Tree",
component: EntityListTree,
};
export default meta;
const onClick = noop;
const nameEditorConfig = {
canEdit: true,
isEditing: false,
isLoading: false,
onEditComplete: noop,
onNameSave: noop,
validateName: () => null,
};
const Tree: EntityListTreeProps["items"] = [
{
startIcon: <Icon name="apps-line" />,
id: "1",
title: "Parent 1",
isExpanded: true,
onClick,
nameEditorConfig,
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,
},
{
startIcon: <Icon name="apps-line" />,
id: "1.1.2",
isDisabled: true,
title: "Grandchild 2",
isExpanded: false,
onClick,
nameEditorConfig,
},
],
},
{
startIcon: <Icon name="apps-line" />,
id: "1.2",
title: "Child 2",
isExpanded: false,
onClick,
nameEditorConfig,
},
],
},
{
startIcon: <Icon name="apps-line" />,
id: "2",
title: "Parent 2",
isExpanded: false,
onClick,
nameEditorConfig,
},
];
const treeUpdate = (
items: EntityListTreeProps["items"],
updater: (item: EntityListTreeItem) => EntityListTreeItem,
) => {
return items.map((item): EntityListTreeItem => {
return {
...updater(item),
children: item.children ? treeUpdate(item.children, updater) : undefined,
};
});
};
const Template = (props: { outsideSelection: string }) => {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
const [selected, setSelected] = React.useState<string | null>(
props.outsideSelection,
);
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);
};
const completeEdit = () => {
setEditing(null);
};
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} />
</Flex>
</ExplorerContainer>
</Flex>
);
};
export const Basic = Template.bind({}) as StoryObj;
Basic.args = {
outsideSelection: "1",
};

View File

@ -0,0 +1,68 @@
import styled from "styled-components";
import { Flex } from "../../../Flex";
/**
* This is used to add a spacing when collapse icon is not present
**/
export const CollapseSpacer = styled.div`
width: 17px;
`;
export const PaddingOverrider = styled.div`
width: 100%;
& > div {
/* Override the padding of the entity item since collapsible icon can be on the left
* By default the padding on the left is 8px, so we need to reduce it to 4px
**/
padding-left: 4px;
}
`;
export const CollapseWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 16px;
width: 16px;
border-radius: var(--ads-v2-border-radius);
cursor: pointer;
`;
export const EntityItemWrapper = styled(Flex)<{ "data-depth": number }>`
border-radius: var(--ads-v2-border-radius);
cursor: pointer;
padding-left: ${(props) => {
return 4 + props["data-depth"] * 8;
}}px;
&[data-selected="true"] {
background-color: var(--ads-v2-colors-content-surface-active-bg);
}
/* disabled style */
&[data-disabled="true"] {
cursor: not-allowed;
opacity: var(--ads-v2-opacity-disabled);
background-color: var(--ads-v2-colors-content-surface-default-bg);
}
&:hover {
background-color: var(--ads-v2-colors-content-surface-hover-bg);
}
&:active {
background-color: var(--ads-v2-colors-content-surface-active-bg);
}
/* Focus styles */
&:focus-visible {
outline: var(--ads-v2-border-width-outline) solid
var(--ads-v2-color-outline);
outline-offset: var(--ads-v2-offset-outline);
}
`;

View File

@ -0,0 +1,123 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { EntityListTree } from "./EntityListTree";
import type { 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 mockOnClick = jest.fn();
const defaultProps: EntityListTreeProps = {
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: [],
},
],
},
],
onItemExpand: mockOnItemExpand,
};
describe("EntityListTree", () => {
it("renders the EntityListTree component", () => {
render(<EntityListTree {...defaultProps} />);
expect(screen.getByRole("tree")).toBeInTheDocument();
});
it("calls onItemExpand when expand icon is clicked", () => {
render(<EntityListTree {...defaultProps} />);
const expandIcon = screen.getByTestId("entity-item-expand-icon");
fireEvent.click(expandIcon);
expect(mockOnItemExpand).toHaveBeenCalledWith("1");
});
it("does not call onItemExpand when item has no children", () => {
const props = {
...defaultProps,
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");
expect(
screen.getByRole("treeitem", { name: "No Children Parent" }),
).toBeInTheDocument();
expect(expandIcon).toBeNull();
});
it("renders nested EntityListTree when item is expanded", () => {
const props = {
...defaultProps,
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: [],
},
],
},
],
};
render(<EntityListTree {...props} />);
expect(screen.getByRole("treeitem", { name: "Child" })).toBeInTheDocument();
});
it("does not render nested EntityListTree when item is not expanded", () => {
render(<EntityListTree {...defaultProps} />);
expect(screen.queryByRole("treeitem", { name: "Child" })).toBeNull();
});
});

View File

@ -0,0 +1,82 @@
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,
CollapseWrapper,
EntityItemWrapper,
} from "./EntityListTree.styles";
export function EntityListTree(props: EntityListTreeProps) {
const { onItemExpand } = props;
const handleOnExpandClick = useCallback(
(event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
// Stop the event from bubbling up to the parent to avoid selection of the item
event.stopPropagation();
const id = event.currentTarget.getAttribute("data-itemid");
if (id) {
onItemExpand(id);
}
},
[onItemExpand],
);
const currentDepth = props.depth || 0;
const childrenDepth = currentDepth + 1;
return (
<Flex
flex="1"
flexDirection="column"
role={currentDepth == 0 ? "tree" : undefined}
>
{props.items.map((item) => (
<Flex flex="1" flexDirection="column" key={item.id}>
<EntityItemWrapper
alignItems="center"
aria-expanded={item.isExpanded}
aria-level={currentDepth}
aria-selected={item.isSelected}
data-depth={currentDepth}
data-disabled={item.isDisabled || false}
data-selected={item.isSelected}
flexDirection="row"
role="treeitem"
>
{item.children && item.children.length ? (
<CollapseWrapper
data-itemid={item.id}
data-testid="entity-item-expand-icon"
onClick={handleOnExpandClick}
>
<Icon
name={
item.isExpanded ? "arrow-down-s-line" : "arrow-right-s-line"
}
size="md"
/>
</CollapseWrapper>
) : (
<CollapseSpacer />
)}
<PaddingOverrider>
<EntityItem {...item} />
</PaddingOverrider>
</EntityItemWrapper>
{item.children && item.isExpanded ? (
<EntityListTree
depth={childrenDepth}
items={item.children}
onItemExpand={onItemExpand}
/>
) : null}
</Flex>
))}
</Flex>
);
}

View File

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

View File

@ -0,0 +1,2 @@
export { EntityListTree } from "./EntityListTree";
export { type EntityListTreeItem } from "./EntityListTree.types";

View File

@ -1,5 +1,6 @@
import React from "react";
import { ExplorerContainerBorder, Flex } from "../../..";
import { Flex } from "../../../Flex";
import { ExplorerContainerBorder } from "./ExplorerContainer.constants";
import type { ExplorerContainerProps } from "./ExplorerContainer.types";
export const ExplorerContainer = (props: ExplorerContainerProps) => {

View File

@ -1,5 +1,5 @@
import React from "react";
import { Text } from "../../..";
import { Text } from "../../../Text";
import type { NoSearchResultsProps } from "./NoSearchResults.types";
const NoSearchResults = ({ text }: NoSearchResultsProps) => {

View File

@ -1,6 +1,6 @@
import styled from "styled-components";
import { Button } from "@appsmith/ads";
import { Button } from "../../../Button";
export const Root = styled.div`
display: flex;

View File

@ -1,6 +1,6 @@
import React, { forwardRef } from "react";
import { SearchInput } from "@appsmith/ads";
import { SearchInput } from "../../../SearchInput";
import * as Styles from "./SearchAndAdd.styles";
import type { SearchAndAddProps } from "./SearchAndAdd.types";

View File

@ -8,3 +8,4 @@ export * from "./ExplorerContainer";
export * from "./EntityItem";
export { useEditableText } from "./Editable";
export * from "./EntityGroupsList";
export * from "./EntityListTree";

View File

@ -1,3 +1,4 @@
export { EditableName } from "./EditableName";
export { RenameMenuItem } from "./RenameMenuItem";
export { useIsRenaming } from "./useIsRenaming";
export { useValidateEntityName } from "./useValidateEntityName";

View File

@ -10,7 +10,7 @@ import { getUsedActionNames } from "selectors/actionSelectors";
import { isNameValid } from "utils/helpers";
interface UseValidateEntityNameProps {
entityName: string;
entityName?: string;
nameErrorMessage?: (name: string) => string;
}
@ -26,10 +26,10 @@ export function useValidateEntityName(props: UseValidateEntityNameProps) {
);
return useCallback(
(name: string): string | null => {
(name: string, oldName: string | undefined = entityName): string | null => {
if (!name || name.trim().length === 0) {
return createMessage(ACTION_INVALID_NAME_ERROR);
} else if (name !== entityName && !isNameValid(name, usedEntityNames)) {
} else if (name !== oldName && !isNameValid(name, usedEntityNames)) {
return createMessage(nameErrorMessage, name);
}

View File

@ -48,6 +48,7 @@ export {
EditableName,
RenameMenuItem,
useIsRenaming,
useValidateEntityName,
} from "./Components/EditableName";
/* ====================================================

View File

@ -1,13 +1,18 @@
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
export const initExplorerEntityNameEdit = (actionId: string) => {
export const initExplorerEntityNameEdit = (entityId: string) => {
return {
type: ReduxActionTypes.INIT_EXPLORER_ENTITY_NAME_EDIT,
payload: {
id: actionId,
id: entityId,
},
};
};
export const endExplorerEntityNameEdit = () => {
return {
type: ReduxActionTypes.END_EXPLORER_ENTITY_NAME_EDIT,
};
};
/**
* action that make explorer pin/unpin

View File

@ -55,6 +55,7 @@ export const FEATURE_FLAG = {
"config_mask_session_recordings_enabled",
config_user_session_recordings_enabled:
"config_user_session_recordings_enabled",
release_ads_entity_item_enabled: "release_ads_entity_item_enabled",
} as const;
export type FeatureFlag = keyof typeof FEATURE_FLAG;
@ -101,6 +102,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = {
kill_session_recordings_enabled: false,
config_user_session_recordings_enabled: true,
config_mask_session_recordings_enabled: true,
release_ads_entity_item_enabled: false,
};
export const AB_TESTING_EVENT_KEYS = {

View File

@ -1072,9 +1072,6 @@ export const getExistingActionNames = createSelector(
},
);
export const getEditingEntityName = (state: AppState) =>
state.ui.explorer.entity.editingEntityName;
export const getExistingJSCollectionNames = createSelector(
getJSCollections,
(jsActions) =>

View File

@ -0,0 +1,43 @@
import React, { useMemo } from "react";
import styled from "styled-components";
import { Flex } from "@appsmith/ads";
import { useSelector } from "react-redux";
import { getCurrentBasePageId } from "selectors/editorSelectors";
import { selectWidgetsForCurrentPage } from "ee/selectors/entitiesSelector";
import WidgetEntity from "./WidgetEntity";
const ListContainer = styled(Flex)`
& .t--entity-item {
height: 32px;
}
`;
export const OldWidgetEntityList = () => {
const basePageId = useSelector(getCurrentBasePageId) as string;
const widgets = useSelector(selectWidgetsForCurrentPage);
const widgetsInStep = useMemo(() => {
return widgets?.children?.map((child) => child.widgetId) || [];
}, [widgets?.children]);
if (!widgets) return null;
if (!widgets.children) return null;
return (
<ListContainer flexDirection="column">
{widgets.children.map((child) => (
<WidgetEntity
basePageId={basePageId}
childWidgets={child.children}
key={child.widgetId}
searchKeyword=""
step={1}
widgetId={child.widgetId}
widgetName={child.widgetName}
widgetType={child.type}
widgetsInStep={widgetsInStep}
/>
))}
</ListContainer>
);
};

View File

@ -2,15 +2,11 @@ import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { initExplorerEntityNameEdit } from "actions/explorerActions";
import type { AppState } from "ee/reducers";
import {
ReduxActionTypes,
WidgetReduxActionTypes,
} from "ee/constants/ReduxActionConstants";
import WidgetFactory from "WidgetProvider/factory";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
import { ENTITY_TYPE } from "entities/DataTree/dataTreeFactory";
import type { TreeDropdownOption } from "pages/Editor/Explorer/ContextMenu";
import ContextMenu from "pages/Editor/Explorer/ContextMenu";
const WidgetTypes = WidgetFactory.widgetTypes;
import { useDeleteWidget } from "pages/Editor/IDE/EditorPane/UI/UIEntityListTree/hooks/useDeleteWidget";
export function WidgetContextMenu(props: {
widgetId: string;
@ -19,46 +15,14 @@ export function WidgetContextMenu(props: {
canManagePages?: boolean;
}) {
const { widgetId } = props;
const parentId = useSelector((state: AppState) => {
return state.ui.pageWidgets[props.pageId].dsl[props.widgetId].parentId;
});
const widget = useSelector((state: AppState) => {
return state.ui.pageWidgets[props.pageId].dsl[props.widgetId];
});
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parentWidget: any = useSelector((state: AppState) => {
if (parentId) return state.ui.pageWidgets[props.pageId].dsl[parentId];
return {};
});
const dispatch = useDispatch();
const dispatchDelete = useCallback(() => {
// If the widget is a tab we are updating the `tabs` of the property of the widget
// This is similar to deleting a tab from the property pane
if (widget.tabName && parentWidget.type === WidgetTypes.TABS_WIDGET) {
const tabsObj = { ...parentWidget.tabsObj };
const filteredTabs = Object.values(tabsObj);
if (widget.parentId && !!filteredTabs.length) {
dispatch({
type: ReduxActionTypes.WIDGET_DELETE_TAB_CHILD,
payload: { ...tabsObj[widget.tabId] },
});
}
return;
}
dispatch({
type: WidgetReduxActionTypes.WIDGET_DELETE,
payload: {
widgetId,
parentId,
},
});
}, [dispatch, widgetId, parentId, widget, parentWidget]);
const deleteWidget = useDeleteWidget(widgetId);
const showBinding = useCallback((widgetId, widgetName) => {
dispatch({
@ -97,7 +61,7 @@ export function WidgetContextMenu(props: {
if (widget.isDeletable !== false && props.canManagePages) {
const option: TreeDropdownOption = {
value: "delete",
onSelect: dispatchDelete,
onSelect: deleteWidget,
label: "Delete",
intent: "danger",
confirmDelete: true,

View File

@ -4,7 +4,6 @@ import Entity, { EntityClassNames } from "../Entity";
import type { WidgetProps } from "widgets/BaseWidget";
import type { WidgetType } from "constants/WidgetConstants";
import { useSelector } from "react-redux";
import WidgetContextMenu from "./WidgetContextMenu";
import { updateWidgetName } from "actions/propertyPaneActions";
import type { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer";
import { getLastSelectedWidget, getSelectedWidgets } from "selectors/ui";
@ -20,6 +19,7 @@ import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getHasManagePagePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
import { convertToPageIdSelector } from "selectors/pageListSelectors";
import WidgetContextMenu from "./WidgetContextMenu";
export type WidgetTree = WidgetProps & { children?: WidgetTree[] };

View File

@ -1,13 +1,9 @@
import React, { useCallback, useEffect, useMemo } from "react";
import { Button, Flex } from "@appsmith/ads";
import WidgetEntity from "pages/Editor/Explorer/Widgets/WidgetEntity";
import { useSelector } from "react-redux";
import { selectWidgetsForCurrentPage } from "ee/selectors/entitiesSelector";
import {
getCurrentBasePageId,
getPagePermissions,
} from "selectors/editorSelectors";
import { getPagePermissions } from "selectors/editorSelectors";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getHasManagePagePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers";
@ -15,19 +11,13 @@ import { createMessage, EDITOR_PANE_TEXTS } from "ee/constants/messages";
import { EmptyState } from "@appsmith/ads";
import history from "utils/history";
import { builderURL } from "ee/RouteBuilder";
import styled from "styled-components";
const ListContainer = styled(Flex)`
& .t--entity-item {
height: 32px;
}
`;
import { UIEntityListTree } from "./UIEntityListTree";
import { OldWidgetEntityList } from "pages/Editor/Explorer/Widgets/OldWidgetEntityList";
const ListWidgets = (props: {
setFocusSearchInput: (focusSearchInput: boolean) => void;
}) => {
const { setFocusSearchInput } = props;
const basePageId = useSelector(getCurrentBasePageId) as string;
const widgets = useSelector(selectWidgetsForCurrentPage);
const pagePermissions = useSelector(getPagePermissions);
const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled);
@ -37,10 +27,6 @@ const ListWidgets = (props: {
pagePermissions,
);
const widgetsInStep = useMemo(() => {
return widgets?.children?.map((child) => child.widgetId) || [];
}, [widgets?.children]);
const addButtonClickHandler = useCallback(() => {
setFocusSearchInput(true);
history.push(builderURL({}));
@ -66,13 +52,12 @@ const ListWidgets = (props: {
[addButtonClickHandler, canManagePages],
);
const isNewWidgetTreeEnabled = useFeatureFlag(
FEATURE_FLAG.release_ads_entity_item_enabled,
);
return (
<ListContainer
flexDirection="column"
gap="spaces-3"
overflow="hidden"
py="spaces-3"
>
<Flex flexDirection="column" gap="spaces-3" overflow="hidden" py="spaces-3">
{!widgetsExist ? (
/* If no widgets exist, show the blank state */
<EmptyState
@ -102,25 +87,18 @@ const ListWidgets = (props: {
data-testid="t--ide-list"
flex="1"
flexDirection={"column"}
overflowX="hidden"
overflowY="auto"
px="spaces-3"
>
{widgets?.children?.map((child) => (
<WidgetEntity
basePageId={basePageId}
childWidgets={child.children}
key={child.widgetId}
searchKeyword=""
step={1}
widgetId={child.widgetId}
widgetName={child.widgetName}
widgetType={child.type}
widgetsInStep={widgetsInStep}
/>
))}
{isNewWidgetTreeEnabled ? (
<UIEntityListTree />
) : (
<OldWidgetEntityList />
)}
</Flex>
) : null}
</ListContainer>
</Flex>
);
};

View File

@ -0,0 +1,73 @@
import React, { useCallback } from "react";
import { EntityListTree } from "@appsmith/ads";
import { useDispatch, 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 "../../hooks/useNameEditorState";
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} />;
};

View File

@ -0,0 +1,101 @@
import React, { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getWidgetByID } from "sagas/selectors";
import { useCallback } from "react";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
import { ENTITY_TYPE } from "ee/entities/DataTree/types";
import { initExplorerEntityNameEdit } from "actions/explorerActions";
import {
Button,
Menu,
MenuContent,
MenuItem,
MenuTrigger,
} from "@appsmith/ads";
import { useBoolean } from "usehooks-ts";
import {
CONTEXT_DELETE,
CONTEXT_RENAME,
CONTEXT_SHOW_BINDING,
createMessage,
} from "ee/constants/messages";
import { useDeleteWidget } from "./hooks/useDeleteWidget";
export const WidgetContextMenu = (props: {
widgetId: string;
canManagePages: boolean;
}) => {
const { canManagePages, widgetId } = props;
const { toggle: toggleMenuOpen, value: isMenuOpen } = useBoolean(false);
const dispatch = useDispatch();
const widget = useSelector(getWidgetByID(widgetId));
const showBinding = useCallback(() => {
dispatch({
type: ReduxActionTypes.SET_ENTITY_INFO,
payload: {
entityId: widgetId,
entityName: widget?.widgetName,
entityType: ENTITY_TYPE.WIDGET,
show: true,
},
});
}, [dispatch, widget?.widgetName, widgetId]);
const editWidgetName = useCallback(() => {
// We add a delay to avoid having the focus stuck in the menu trigger
setTimeout(() => {
dispatch(initExplorerEntityNameEdit(widgetId));
}, 100);
}, [dispatch, widgetId]);
const deleteWidget = useDeleteWidget(widgetId);
const menuContent = useMemo(() => {
return (
<>
<MenuItem onClick={showBinding}>
{createMessage(CONTEXT_SHOW_BINDING)}
</MenuItem>
<MenuItem
disabled={!canManagePages}
onClick={editWidgetName}
startIcon="input-cursor-move"
>
{createMessage(CONTEXT_RENAME)}
</MenuItem>
<MenuItem
className="error-menuitem"
disabled={!canManagePages && widget?.isDeletable !== false}
onClick={deleteWidget}
startIcon="trash"
>
{createMessage(CONTEXT_DELETE)}
</MenuItem>
</>
);
}, [
canManagePages,
deleteWidget,
editWidgetName,
showBinding,
widget?.isDeletable,
]);
return (
<Menu onOpenChange={toggleMenuOpen} open={isMenuOpen}>
<MenuTrigger>
<Button
data-testid="t--more-action-trigger"
isIconButton
kind="tertiary"
startIcon="more-2-fill"
/>
</MenuTrigger>
<MenuContent align="start" key={widgetId} side="right" width="300px">
{menuContent}
</MenuContent>
</Menu>
);
};

View File

@ -0,0 +1,21 @@
import React from "react";
import WidgetFactory from "WidgetProvider/factory";
import WidgetIcon from "pages/Editor/Explorer/Widgets/WidgetIcon";
interface WidgetTypeIconProps {
type: string;
}
export const WidgetTypeIcon: React.FC<WidgetTypeIconProps> = React.memo(
({ type }) => {
const { IconCmp } = WidgetFactory.getWidgetMethods(type);
if (IconCmp) {
return <IconCmp />;
}
return <WidgetIcon type={type} />;
},
);
WidgetTypeIcon.displayName = "WidgetTypeIcon";

View File

@ -0,0 +1,48 @@
import { useDispatch, useSelector } from "react-redux";
import { getWidgetByID } from "sagas/selectors";
import { useCallback } from "react";
import {
ReduxActionTypes,
WidgetReduxActionTypes,
} from "ee/constants/ReduxActionConstants";
import { getParentWidget } from "selectors/widgetSelectors";
import WidgetFactory from "WidgetProvider/factory";
const WidgetTypes = WidgetFactory.widgetTypes;
export function useDeleteWidget(widgetId: string): () => void {
const dispatch = useDispatch();
const widget = useSelector(getWidgetByID(widgetId));
const parentWidget = useSelector((state) => getParentWidget(state, widgetId));
return useCallback(() => {
// If the widget is a tab we are updating the `tabs` of the property of the widget
// This is similar to deleting a tab from the property pane
if (
widget?.tabName &&
parentWidget &&
parentWidget.type === WidgetTypes.TABS_WIDGET
) {
const tabsObj = { ...parentWidget.tabsObj };
const filteredTabs = Object.values(tabsObj);
if (widget?.parentId && !!filteredTabs.length) {
dispatch({
type: ReduxActionTypes.WIDGET_DELETE_TAB_CHILD,
payload: { ...tabsObj[widget?.tabId] },
});
}
return;
}
dispatch({
type: WidgetReduxActionTypes.WIDGET_DELETE,
payload: {
widgetId,
parentId: widget?.parentId,
},
});
}, [dispatch, parentWidget, widget, widgetId]);
}

View File

@ -0,0 +1,42 @@
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";
import { useNavigateToWidget } from "pages/Editor/Explorer/Widgets/useNavigateToWidget";
import { useSelector } from "react-redux";
import { getCurrentBasePageId } from "selectors/editorSelectors";
import { getSelectedWidgets } from "selectors/ui";
export function useSwitchToWidget() {
const { navigateToWidget } = useNavigateToWidget();
const basePageId = useSelector(getCurrentBasePageId) as string;
const selectedWidgets = useSelector(getSelectedWidgets);
return useCallback(
(e: MouseEvent, widget: CanvasStructure) => {
const isMultiSelect = e.metaKey || e.ctrlKey;
const isShiftSelect = e.shiftKey;
AnalyticsUtil.logEvent("ENTITY_EXPLORER_CLICK", {
type: "WIDGETS",
fromUrl: location.pathname,
toUrl: `${builderURL({
basePageId,
hash: widget.widgetId,
})}`,
name: widget.widgetName,
});
navigateToWidget(
widget.widgetId,
widget.type,
basePageId,
NavigationMethod.EntityExplorer,
selectedWidgets.includes(widget.widgetId),
isMultiSelect,
isShiftSelect,
);
},
[basePageId, navigateToWidget, selectedWidgets],
);
}

View File

@ -0,0 +1,34 @@
import { useCallback, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { getEntityExplorerWidgetsToExpand } from "selectors/widgetSelectors";
export const useWidgetTreeState = () => {
const widgetsToExpand = useSelector(getEntityExplorerWidgetsToExpand);
const [expandedWidgets, setExpandedWidgets] =
useState<string[]>(widgetsToExpand);
const handleExpand = useCallback((id: string) => {
setExpandedWidgets((prev) =>
prev.includes(id)
? prev.filter((widgetId) => widgetId !== id)
: [...prev, id],
);
}, []);
useEffect(
function handleExpandedWidgetsUpdate() {
// Merge current expanded with new list
// This is to ensure that the expanded widgets are not lost when the list is updated
setExpandedWidgets((prev) => [
...prev,
...widgetsToExpand.filter((widgetId) => !prev.includes(widgetId)),
]);
},
[widgetsToExpand],
);
return {
expandedWidgets,
handleExpand,
};
};

View File

@ -0,0 +1 @@
export { UIEntityListTree } from "./UIEntityListTree";

View File

@ -0,0 +1,16 @@
import type { CanvasStructure } from "reducers/uiReducers/pageCanvasStructureReducer";
import type { EntityListTreeItem } from "@appsmith/ads";
export const enhanceItemsTree = (
items: CanvasStructure[],
enhancer: (item: CanvasStructure) => EntityListTreeItem,
) => {
return items.map((child): EntityListTreeItem => {
return {
...enhancer(child),
children: child.children
? enhanceItemsTree(child.children, enhancer)
: undefined,
};
});
};

View File

@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { initExplorerEntityNameEdit } from "actions/explorerActions";
import { ReduxActionTypes } from "ee/constants/ReduxActionConstants";
import {
getUpdatingEntity,
getEditingEntityName,
} from "selectors/explorerSelector";
export function useNameEditorState() {
const dispatch = useDispatch();
const editingEntity = useSelector(getEditingEntityName);
const updatingEntity = useSelector(getUpdatingEntity);
const enterEditMode = useCallback(
(id: string) => {
dispatch(initExplorerEntityNameEdit(id));
},
[dispatch],
);
const exitEditMode = useCallback(() => {
dispatch({
type: ReduxActionTypes.END_EXPLORER_ENTITY_NAME_EDIT,
});
}, [dispatch]);
return {
enterEditMode,
exitEditMode,
editingEntity,
updatingEntity,
};
}

View File

@ -34,3 +34,5 @@ export const getExplorerActive = (state: AppState) => {
export const getUpdatingEntity = (state: AppState) => {
return state.ui.explorer.entity.updatingEntity;
};
export const getEditingEntityName = (state: AppState) =>
state.ui.explorer.entity.editingEntityName;