diff --git a/app/client/packages/design-system/ads/src/List/List.stories.tsx b/app/client/packages/design-system/ads/src/List/List.stories.tsx
index cd1acd256d6b..2b86c011a01b 100644
--- a/app/client/packages/design-system/ads/src/List/List.stories.tsx
+++ b/app/client/packages/design-system/ads/src/List/List.stories.tsx
@@ -210,6 +210,7 @@ export const ListItemBlockDescStory = ListItemTemplate.bind({}) as StoryObj;
ListItemBlockDescStory.storyName = "List item block description";
ListItemBlockDescStory.argTypes = ListItemArgTypes;
ListItemBlockDescStory.args = {
+ startIcon: ,
title: "Action item 1",
description: "block",
descriptionType: "block",
@@ -219,7 +220,8 @@ export const ListItemOverflowStory = ListItemTemplate.bind({}) as StoryObj;
ListItemOverflowStory.storyName = "List item title overflow";
ListItemOverflowStory.argTypes = ListItemArgTypes;
ListItemOverflowStory.args = {
- title: "Action item 1 Action item 1 Action item 1 Action item 1",
+ title:
+ "Action item 1 Action item 1 Action item 1 Action item 1 Action item 1",
};
export const ListItemRightControlStory = ListItemTemplate.bind({}) as StoryObj;
diff --git a/app/client/packages/design-system/ads/src/List/List.styles.tsx b/app/client/packages/design-system/ads/src/List/List.styles.tsx
index db47e6987f99..29653f478bbf 100644
--- a/app/client/packages/design-system/ads/src/List/List.styles.tsx
+++ b/app/client/packages/design-system/ads/src/List/List.styles.tsx
@@ -31,62 +31,37 @@ export const TooltipTextWrapper = styled.div`
min-width: 0;
`;
-export const DescriptionWrapper = styled.div`
- flex-direction: column;
- min-width: 0;
- gap: var(--ads-v2-spaces-2);
+export const RightControlWrapper = styled.div`
+ height: 100%;
+ line-height: normal;
display: flex;
+ align-items: center;
+
+ button {
+ margin-left: -4px;
+ }
`;
-export const InlineDescriptionWrapper = styled.div`
+export const TopContentWrapper = styled.div`
display: flex;
- justify-content: space-between;
align-items: center;
- flex: 1;
+ gap: var(--ads-v2-spaces-3);
min-width: 0;
+ height: 24px;
+ width: 100%;
`;
-export const RightControlWrapper = styled.div`
- height: 100%;
- line-height: normal;
+export const BottomContentWrapper = styled.div`
+ padding-left: var(--ads-v2-spaces-7);
+ padding-bottom: var(--ads-v2-spaces-2);
`;
-export const ContentTextWrapper = styled.div`
+export const InlineDescriptionWrapper = styled.div`
display: flex;
- width: 100%;
+ justify-content: space-between;
align-items: center;
- box-sizing: border-box;
- gap: var(--ads-v2-spaces-3);
- overflow: hidden;
flex: 1;
min-width: 0;
-
- & .${ListItemTextOverflowClassName} {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- & .${ListItemTitleClassName} {
- font-size: var(--listitem-title-font-size);
- line-height: 16px;
- }
-
- & .${ListItemBDescClassName} {
- -webkit-line-clamp: 1;
- display: -webkit-box;
- -webkit-box-orient: vertical;
- text-overflow: initial;
- white-space: initial;
- font-size: var(--listitem-bdescription-font-size);
- line-height: normal;
- }
-
- & .${ListItemIDescClassName} {
- font-size: var(--listitem-idescription-font-size);
- line-height: 16px;
- padding-right: var(--ads-v2-spaces-2);
- }
`;
export const StyledList = styled.div`
@@ -106,28 +81,24 @@ export const StyledListItem = styled.div<{
display: flex;
width: 100%;
- align-items: center;
cursor: pointer;
box-sizing: border-box;
position: relative;
border-radius: var(--ads-v2-border-radius);
padding: var(--ads-v2-spaces-2);
padding-left: var(--ads-v2-spaces-3);
+ gap: var(--ads-v2-spaces-1);
+ flex: 1;
+ flex-shrink: 0;
+ flex-direction: column;
${({ size }) => Sizes[size]}
- &[data-isblockdescription="true"] {
- height: 54px;
- }
-
- &[data-isblockdescription="false"] {
- height: 32px;
- }
-
&[data-rightcontrolvisibility="hover"] {
${RightControlWrapper} {
display: none;
}
+
&:hover ${RightControlWrapper} {
display: block;
}
@@ -138,6 +109,7 @@ export const StyledListItem = styled.div<{
}
/* disabled style */
+
&[data-disabled="true"] {
cursor: not-allowed;
opacity: var(--ads-v2-opacity-disabled);
@@ -153,9 +125,38 @@ export const StyledListItem = styled.div<{
}
/* Focus styles */
+
&:focus-visible {
outline: var(--ads-v2-border-width-outline) solid
var(--ads-v2-color-outline);
outline-offset: var(--ads-v2-offset-outline);
}
+
+ & .${ListItemTextOverflowClassName} {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ flex: 1;
+ }
+
+ & .${ListItemTitleClassName} {
+ font-size: var(--listitem-title-font-size);
+ line-height: 16px;
+ }
+
+ & .${ListItemBDescClassName} {
+ -webkit-line-clamp: 1;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ text-overflow: initial;
+ white-space: initial;
+ font-size: var(--listitem-bdescription-font-size);
+ line-height: normal;
+ }
+
+ & .${ListItemIDescClassName} {
+ font-size: var(--listitem-idescription-font-size);
+ line-height: 16px;
+ padding-right: var(--ads-v2-spaces-2);
+ }
`;
diff --git a/app/client/packages/design-system/ads/src/List/List.tsx b/app/client/packages/design-system/ads/src/List/List.tsx
index 85fc97c212ab..a6d74126c84a 100644
--- a/app/client/packages/design-system/ads/src/List/List.tsx
+++ b/app/client/packages/design-system/ads/src/List/List.tsx
@@ -3,13 +3,13 @@ import clsx from "classnames";
import type { ListItemProps, ListProps } from "./List.types";
import {
- ContentTextWrapper,
- DescriptionWrapper,
+ BottomContentWrapper,
InlineDescriptionWrapper,
RightControlWrapper,
StyledList,
StyledListItem,
TooltipTextWrapper,
+ TopContentWrapper,
} from "./List.styles";
import type { TextProps } from "../Text";
import { Text } from "../Text";
@@ -89,7 +89,8 @@ function ListItem(props: ListItemProps) {
startIcon,
title,
} = props;
- const isBlockDescription = descriptionType === "block";
+ const isBlockDescription = descriptionType === "block" && description;
+ const isInlineDescription = descriptionType === "inline" && description;
const handleOnClick = () => {
if (!props.isDisabled && props.onClick) {
@@ -97,6 +98,12 @@ function ListItem(props: ListItemProps) {
}
};
+ const handleDoubleClick = () => {
+ if (!props.isDisabled && props.onDoubleClick) {
+ props.onDoubleClick();
+ }
+ };
+
const handleRightControlClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
@@ -105,38 +112,26 @@ function ListItem(props: ListItemProps) {
-
+
{startIcon}
{props.customTitleComponent ? (
props.customTitleComponent
) : (
-
-
- {title}
-
- {isBlockDescription && description && (
-
- {description}
-
- )}
-
- {!isBlockDescription && description && (
+
+ {title}
+
+ {isInlineDescription && (
)}
-
- {rightControl && (
-
- {rightControl}
-
+ {rightControl && (
+
+ {rightControl}
+
+ )}
+
+ {isBlockDescription && (
+
+
+ {description}
+
+
)}
);
diff --git a/app/client/packages/design-system/ads/src/List/List.types.tsx b/app/client/packages/design-system/ads/src/List/List.types.tsx
index a39db8ec5310..ecf63fc6522c 100644
--- a/app/client/packages/design-system/ads/src/List/List.types.tsx
+++ b/app/client/packages/design-system/ads/src/List/List.types.tsx
@@ -8,10 +8,12 @@ export interface ListItemProps {
startIcon?: ReactNode;
/** The control to display at the end. */
rightControl?: ReactNode;
- /** */
+ /** Control the visibility trigger of right control */
rightControlVisibility?: "hover" | "always";
/** callback for when the list item is clicked */
onClick: () => void;
+ /** callback for when the list item is double-clicked */
+ onDoubleClick?: () => void;
/** Whether the list item is disabled. */
isDisabled?: boolean;
/** Whether the list item is selected. */
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/index.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/index.ts
new file mode 100644
index 000000000000..2a2fbaf6fcf7
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/index.ts
@@ -0,0 +1 @@
+export { useEditableText } from "./useEditableText";
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.test.tsx b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.test.tsx
new file mode 100644
index 000000000000..455c3f8cc612
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.test.tsx
@@ -0,0 +1,206 @@
+import React from "react";
+import { renderHook, act } from "@testing-library/react-hooks";
+import { useEditableText } from "./useEditableText";
+import { fireEvent, render } from "@testing-library/react";
+import { Text } from "../../..";
+
+describe("useEditableText", () => {
+ const mockExitEditing = jest.fn();
+ const mockOnNameSave = jest.fn();
+ const mockValidateName = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test("initial state", () => {
+ mockValidateName.mockReturnValueOnce(null);
+ const { result } = renderHook(() =>
+ useEditableText(
+ false,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [inputRef, editableName, validationError] = result.current;
+
+ expect(editableName).toBe("initial_name");
+ expect(validationError).toBeNull();
+ expect(inputRef.current).toBeNull();
+ });
+
+ test("handle name change", () => {
+ mockValidateName.mockReturnValueOnce(null);
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [, , , , handleTitleChange] = result.current;
+
+ act(() => {
+ handleTitleChange({
+ target: { value: "new_name" },
+ } as React.ChangeEvent);
+ });
+
+ const [, editableName, validationError] = result.current;
+
+ expect(editableName).toBe("new_name");
+ expect(validationError).toBeNull();
+ });
+
+ test("handle valid name save on Enter key", () => {
+ mockValidateName.mockReturnValueOnce(null);
+
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [, , , handleKeyUp, handleTitleChange] = result.current;
+
+ act(() => {
+ handleTitleChange({
+ target: { value: "new_name" },
+ } as React.ChangeEvent);
+ });
+
+ act(() => {
+ handleKeyUp({
+ key: "Enter",
+ } as unknown as React.KeyboardEvent);
+ });
+
+ expect(mockOnNameSave).toHaveBeenCalledWith("new_name");
+ expect(mockExitEditing).toHaveBeenCalled();
+ });
+
+ test("handle invalid name save on Enter key", () => {
+ mockValidateName.mockReturnValue("Invalid");
+
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [, , , handleKeyUp, handleTitleChange] = result.current;
+
+ act(() => {
+ handleTitleChange({
+ target: { value: "invalid_name" },
+ } as React.ChangeEvent);
+ });
+
+ act(() => {
+ handleKeyUp({
+ key: "Enter",
+ } as unknown as React.KeyboardEvent);
+ });
+
+ expect(mockOnNameSave).not.toHaveBeenCalled();
+ expect(mockExitEditing).toHaveBeenCalled();
+ });
+
+ test("handle exit without saving on Escape key", () => {
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [, , , handleKeyUp] = result.current;
+
+ act(() => {
+ handleKeyUp({ key: "Escape" } as React.KeyboardEvent);
+ });
+
+ expect(mockExitEditing).toHaveBeenCalled();
+ expect(mockOnNameSave).not.toHaveBeenCalled();
+ });
+
+ test("handle exit without saving on no change", () => {
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [, , , handleKeyUp] = result.current;
+
+ act(() => {
+ handleKeyUp({ key: "Enter" } as React.KeyboardEvent);
+ });
+
+ expect(mockExitEditing).toHaveBeenCalled();
+ expect(mockOnNameSave).not.toHaveBeenCalled();
+ });
+
+ test("handle focus out event", () => {
+ mockValidateName.mockReturnValue(null);
+ const { result } = renderHook(() =>
+ useEditableText(
+ true,
+ "initial_name",
+ mockExitEditing,
+ mockValidateName,
+ mockOnNameSave,
+ ),
+ );
+
+ const [inputRef, , , , handleChange] = result.current;
+
+ const inputProps = { onChange: handleChange };
+
+ const TestComponent = () => {
+ return (
+
+ Text
+
+ );
+ };
+
+ render();
+
+ act(() => {
+ handleChange({
+ target: { value: "new_name" },
+ } as React.ChangeEvent);
+ });
+
+ act(() => {
+ if (inputRef.current) {
+ fireEvent.focusOut(inputRef.current);
+ }
+ });
+
+ expect(mockOnNameSave).toHaveBeenCalledWith("new_name");
+ expect(mockExitEditing).toHaveBeenCalled();
+ });
+});
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.ts
new file mode 100644
index 000000000000..ac8d6d5e621b
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/useEditableText.ts
@@ -0,0 +1,137 @@
+import {
+ useCallback,
+ useEffect,
+ useState,
+ type KeyboardEvent,
+ type ChangeEvent,
+ useRef,
+ type RefObject,
+} from "react";
+import { usePrevious } from "@mantine/hooks";
+import { useEventCallback, useEventListener } from "usehooks-ts";
+import { normaliseName } from "./utils";
+
+export function useEditableText(
+ isEditing: boolean,
+ name: string,
+ exitEditing: () => void,
+ validateName: (name: string) => string | null,
+ onNameSave: (name: string) => void,
+): [
+ RefObject,
+ string,
+ string | null,
+ (e: KeyboardEvent) => void,
+ (e: ChangeEvent) => void,
+] {
+ const previousName = usePrevious(name);
+ const [editableName, setEditableName] = useState(name);
+ const [validationError, setValidationError] = useState(null);
+ const inputRef = useRef(null);
+
+ const exitWithoutSaving = useCallback(() => {
+ exitEditing();
+ setEditableName(name);
+ setValidationError(null);
+ }, [exitEditing, name]);
+
+ const validate = useCallback(
+ (name: string) => {
+ const nameError = validateName(name);
+
+ if (nameError === null) {
+ setValidationError(null);
+ } else {
+ setValidationError(nameError);
+ }
+
+ return nameError;
+ },
+ [validateName],
+ );
+
+ const attemptSave = useCallback(() => {
+ const nameError = validate(editableName);
+
+ if (editableName === name) {
+ // No change detected
+ exitWithoutSaving();
+ } else if (nameError === null) {
+ // Save the new name
+ exitEditing();
+ onNameSave(editableName);
+ } else {
+ // Exit edit mode and revert name
+ exitWithoutSaving();
+ }
+ }, [
+ editableName,
+ exitEditing,
+ exitWithoutSaving,
+ name,
+ onNameSave,
+ validate,
+ ]);
+
+ const handleKeyUp = useEventCallback((e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ attemptSave();
+ } else if (e.key === "Escape") {
+ exitWithoutSaving();
+ }
+ });
+
+ const handleTitleChange = useEventCallback(
+ (e: ChangeEvent) => {
+ const value = normaliseName(e.target.value);
+
+ setEditableName(value);
+ validate(value);
+ },
+ );
+
+ useEventListener(
+ "focusout",
+ function handleFocusOut() {
+ const input = inputRef.current;
+
+ if (input) {
+ attemptSave();
+ }
+ },
+ inputRef,
+ );
+
+ useEffect(
+ function syncEditableTitle() {
+ if (!isEditing && previousName !== name) {
+ setEditableName(name);
+ }
+ },
+ [name, previousName, isEditing],
+ );
+
+ // TODO: This is a temporary fix to focus the input after context retention applies focus to its target
+ // this is a nasty hack to re-focus the input after context retention applies focus to its target
+ // this will be addressed in a future task, likely by a focus retention modification
+ useEffect(
+ function recaptureFocusInEventOfFocusRetention() {
+ const input = inputRef.current;
+
+ if (isEditing && input) {
+ setTimeout(() => {
+ input.focus();
+ }, 200);
+ }
+ },
+ [isEditing, inputRef],
+ );
+
+ return [
+ inputRef,
+ editableName,
+ validationError,
+ handleKeyUp,
+ handleTitleChange,
+ ];
+}
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/utils.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/utils.ts
new file mode 100644
index 000000000000..8f308d38ec8e
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/Editable/utils.ts
@@ -0,0 +1,8 @@
+export const normaliseName = (value: string, limit?: number) => {
+ const separatorRegex = /\W+/;
+
+ return value
+ .split(separatorRegex)
+ .join("_")
+ .slice(0, limit || 30);
+};
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.stories.tsx b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.stories.tsx
new file mode 100644
index 000000000000..eb4be22a56b6
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.stories.tsx
@@ -0,0 +1,126 @@
+/* eslint-disable no-console */
+import React from "react";
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { EntityItem } from "./EntityItem";
+import type { EntityItemProps } from "./EntityItem.types";
+import { ExplorerContainer } from "../ExplorerContainer";
+import { Flex, Button, Icon, Callout } from "../../..";
+
+const meta: Meta = {
+ title: "ADS/Templates/Entity Explorer/Entity Item",
+ component: EntityItem,
+};
+
+export default meta;
+
+const Template = (props: EntityItemProps) => {
+ const { hasError, isDisabled, isSelected, nameEditorConfig, title } = props;
+ const [isEditing, setIsEditing] = React.useState(false);
+
+ const onEditComplete = () => {
+ setIsEditing(false);
+ };
+ const onNameSave = (name: string) => console.log("Name saved" + name);
+
+ const onClick = () => console.log("Add clicked");
+
+ const rightControl = (
+
+ );
+
+ const startIcon = ;
+
+ return (
+
+
+
+ {
+ setIsEditing(true);
+ }}
+ {...{
+ startIcon,
+ title,
+ hasError,
+ isSelected,
+ isDisabled,
+ nameEditorConfig: {
+ ...nameEditorConfig,
+ isEditing,
+ onEditComplete,
+ onNameSave,
+ },
+ onClick,
+ rightControl,
+ }}
+ />
+ Double click the name to edit it
+
+
+
+ );
+};
+
+export const InEditingMode = Template.bind({}) as StoryObj;
+
+InEditingMode.args = {
+ title: "EntityName",
+ nameEditorConfig: {
+ canEdit: true,
+ validateName: () => null,
+ },
+};
+
+export const NoPermissionToEdit = Template.bind({}) as StoryObj;
+
+NoPermissionToEdit.args = {
+ title: "EntityName",
+ nameEditorConfig: {
+ canEdit: false,
+ validateName: () => null,
+ },
+};
+
+export const RenamingError = Template.bind({}) as StoryObj;
+
+RenamingError.args = {
+ title: "EntityName",
+ nameEditorConfig: {
+ canEdit: true,
+ validateName: () => "This is a sample error",
+ },
+};
+
+export const SelectedState = Template.bind({}) as StoryObj;
+
+SelectedState.args = {
+ title: "EntityName",
+ isSelected: true,
+ nameEditorConfig: {
+ canEdit: true,
+ validateName: () => null,
+ },
+};
+
+export const DisabledState = Template.bind({}) as StoryObj;
+
+DisabledState.args = {
+ title: "EntityName",
+ isDisabled: true,
+ nameEditorConfig: {
+ canEdit: true,
+ validateName: () => null,
+ },
+};
+
+export const LoadingState = Template.bind({}) as StoryObj;
+
+LoadingState.args = {
+ title: "EntityName",
+ nameEditorConfig: {
+ isLoading: true,
+ canEdit: true,
+ validateName: () => null,
+ },
+};
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.styles.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.styles.ts
new file mode 100644
index 000000000000..263b2a15a16c
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.styles.ts
@@ -0,0 +1,16 @@
+import styled from "styled-components";
+import { Text } from "../../..";
+
+export const EntityEditableName = styled(Text)`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ flex: 1;
+
+ &[data-isediting="true"] {
+ height: 32px;
+ line-height: 32px;
+ min-width: 3ch;
+ text-overflow: unset;
+ }
+`;
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.tsx b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.tsx
new file mode 100644
index 000000000000..df92daef4400
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.tsx
@@ -0,0 +1,84 @@
+import React, { useMemo } from "react";
+import { ListItem, Spinner, Tooltip } from "../../..";
+
+import type { EntityItemProps } from "./EntityItem.types";
+import { EntityEditableName } from "./EntityItem.styles";
+import { useEditableText } from "../Editable";
+
+export const EntityItem = (props: EntityItemProps) => {
+ const {
+ canEdit,
+ isEditing,
+ isLoading,
+ onEditComplete,
+ onNameSave,
+ validateName,
+ } = props.nameEditorConfig;
+
+ const inEditMode = canEdit ? isEditing : false;
+
+ const [
+ inputRef,
+ editableName,
+ validationError,
+ handleKeyUp,
+ handleTitleChange,
+ ] = useEditableText(
+ inEditMode,
+ props.title,
+ onEditComplete,
+ validateName,
+ onNameSave,
+ );
+
+ const inputProps = useMemo(
+ () => ({
+ onChange: handleTitleChange,
+ onKeyUp: handleKeyUp,
+ style: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ height: "32px",
+ top: 0,
+ },
+ }),
+ [handleKeyUp, handleTitleChange],
+ );
+
+ const startIcon = useMemo(() => {
+ if (isLoading) {
+ return ;
+ }
+
+ return props.startIcon;
+ }, [isLoading, props.startIcon]);
+
+ const customTitle = useMemo(() => {
+ return (
+
+
+ {editableName}
+
+
+ );
+ }, [editableName, inputProps, inputRef, inEditMode, validationError]);
+
+ return (
+
+ );
+};
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.types.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.types.ts
new file mode 100644
index 000000000000..09244c4b3b1f
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/EntityItem.types.ts
@@ -0,0 +1,23 @@
+import type { ListItemProps } from "../../../List";
+
+export interface EntityItemProps
+ extends Omit<
+ ListItemProps,
+ "customTitleComponent" | "description" | "descriptionType"
+ > {
+ /** Control the name editing behaviour */
+ nameEditorConfig: {
+ // Set editable based on user permissions
+ canEdit: boolean;
+ // State to control the editable state of the input
+ isEditing: boolean;
+ // Shows a loading spinner in place of the startIcon
+ isLoading: boolean;
+ // Called to request the editing mode to end
+ onEditComplete: () => void;
+ // Called to save the new name
+ onNameSave: (newName: string) => void;
+ // Provide a function validate the new name
+ validateName: (newName: string) => string | null;
+ };
+}
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/index.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/index.ts
new file mode 100644
index 000000000000..7f3dc03d3fbb
--- /dev/null
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityItem/index.ts
@@ -0,0 +1 @@
+export { EntityItem } from "./EntityItem";
diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/index.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/index.ts
index 5e42c9c115ba..51c3e9097eaf 100644
--- a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/index.ts
+++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/index.ts
@@ -5,3 +5,5 @@ export * from "./SearchAndAdd";
export { EmptyState } from "./EmptyState";
export { NoSearchResults } from "./NoSearchResults";
export * from "./ExplorerContainer";
+export * from "./EntityItem";
+export { useEditableText } from "./Editable";
diff --git a/app/client/src/IDE/Components/EditableName/EditableName.test.tsx b/app/client/src/IDE/Components/EditableName/EditableName.test.tsx
index eec5e6a26c10..097b1a6af574 100644
--- a/app/client/src/IDE/Components/EditableName/EditableName.test.tsx
+++ b/app/client/src/IDE/Components/EditableName/EditableName.test.tsx
@@ -3,19 +3,30 @@ import { EditableName } from "./EditableName";
import { render } from "test/testUtils";
import "@testing-library/jest-dom";
import { Icon } from "@appsmith/ads";
-import { fireEvent } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
+import { useEditableText } from "@appsmith/ads";
+
+jest.mock("@appsmith/ads", () => ({
+ ...jest.requireActual("@appsmith/ads"),
+ useEditableText: jest.fn(),
+}));
describe("EditableName", () => {
const mockOnNameSave = jest.fn();
const mockOnExitEditing = jest.fn();
+ const mockValidator = jest.fn();
+
+ beforeEach(() => {
+ (useEditableText as jest.Mock).mockReturnValue([
+ { current: { focus: jest.fn() } },
+ "test_name",
+ mockValidator,
+ jest.fn(),
+ jest.fn(),
+ ]);
+ });
const name = "test_name";
const TabIcon = () => ;
- const KEY_CONFIG = {
- ENTER: { key: "Enter", keyCode: 13 },
- ESC: { key: "Esc", keyCode: 27 },
- };
const setup = ({ isEditing = false, isLoading = false }) => {
// Define the props
@@ -56,153 +67,4 @@ describe("EditableName", () => {
expect(inputElement).toBeInTheDocument();
});
-
- describe("valid input actions", () => {
- test("submit event", async () => {
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
-
- // hit enter
- const enterTitle = "enter_title";
-
- fireEvent.change(getByRole("textbox"), {
- target: { value: enterTitle },
- });
- expect(getByRole("textbox")).toHaveValue(enterTitle);
-
- fireEvent.keyUp(getByRole("textbox"), KEY_CONFIG.ENTER);
-
- expect(onNameSave).toHaveBeenCalledWith(enterTitle);
- expect(exitEditing).toHaveBeenCalled();
- });
-
- test("outside click event", async () => {
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
-
- const clickOutsideTitle = "click_outside_title";
-
- fireEvent.change(getByRole("textbox"), {
- target: { value: clickOutsideTitle },
- });
-
- await userEvent.click(document.body);
-
- expect(onNameSave).toHaveBeenCalledWith(clickOutsideTitle);
- expect(exitEditing).toHaveBeenCalled();
- });
-
- test("esc key event", async () => {
- const escapeTitle = "escape_title";
-
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
-
- fireEvent.change(getByRole("textbox"), {
- target: { value: escapeTitle },
- });
-
- fireEvent.keyUp(getByRole("textbox"), KEY_CONFIG.ESC);
-
- expect(exitEditing).toHaveBeenCalled();
- expect(onNameSave).not.toHaveBeenCalledWith(escapeTitle);
- });
-
- test("focus out event", async () => {
- const focusOutTitle = "focus_out_title";
-
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
-
- const inputElement = getByRole("textbox");
-
- fireEvent.change(inputElement, {
- target: { value: focusOutTitle },
- });
-
- fireEvent.keyUp(inputElement, KEY_CONFIG.ESC);
- expect(exitEditing).toHaveBeenCalled();
- expect(onNameSave).not.toHaveBeenCalledWith(focusOutTitle);
- });
- });
-
- describe("invalid input actions", () => {
- const invalidTitle = "else";
- const validationError =
- "else is already being used or is a restricted keyword.";
-
- test("click outside", async () => {
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
- const inputElement = getByRole("textbox");
-
- fireEvent.change(inputElement, {
- target: { value: invalidTitle },
- });
-
- expect(getByRole("tooltip")).toBeInTheDocument();
-
- expect(getByRole("tooltip").textContent).toEqual(validationError);
-
- await userEvent.click(document.body);
-
- expect(getByRole("tooltip").textContent).toEqual("");
-
- expect(exitEditing).toHaveBeenCalled();
- expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle);
- });
-
- test("esc key", async () => {
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
- const inputElement = getByRole("textbox");
-
- fireEvent.change(inputElement, {
- target: { value: invalidTitle },
- });
-
- fireEvent.keyUp(inputElement, KEY_CONFIG.ESC);
-
- expect(getByRole("tooltip")).toBeInTheDocument();
-
- expect(getByRole("tooltip").textContent).toEqual("");
- expect(exitEditing).toHaveBeenCalled();
- expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle);
- });
-
- test("focus out event", async () => {
- const { exitEditing, getByRole, onNameSave } = setup({
- isEditing: true,
- });
- const inputElement = getByRole("textbox");
-
- fireEvent.change(inputElement, {
- target: { value: invalidTitle },
- });
-
- fireEvent.focusOut(inputElement);
- expect(getByRole("tooltip").textContent).toEqual("");
- expect(exitEditing).toHaveBeenCalled();
- expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle);
- });
-
- test("prevents saving empty name", () => {
- const { getByRole, onNameSave } = setup({ isEditing: true });
- const input = getByRole("textbox");
-
- fireEvent.change(input, { target: { value: "" } });
- expect(getByRole("tooltip")).toHaveTextContent(
- "Please enter a valid name",
- );
- fireEvent.keyUp(input, KEY_CONFIG.ENTER);
-
- expect(onNameSave).not.toHaveBeenCalledWith("");
- });
- });
});
diff --git a/app/client/src/IDE/Components/EditableName/EditableName.tsx b/app/client/src/IDE/Components/EditableName/EditableName.tsx
index 0bc2829728cf..1b629e8c4015 100644
--- a/app/client/src/IDE/Components/EditableName/EditableName.tsx
+++ b/app/client/src/IDE/Components/EditableName/EditableName.tsx
@@ -1,15 +1,8 @@
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import React, { useMemo } from "react";
import { Spinner, Text as ADSText, Tooltip } from "@appsmith/ads";
-import { useEventCallback, useEventListener } from "usehooks-ts";
-import { usePrevious } from "@mantine/hooks";
-import { useNameEditor } from "./useNameEditor";
+import { useValidateEntityName } from "./useValidateEntityName";
import styled from "styled-components";
+import { useEditableText } from "@appsmith/ads";
interface EditableTextProps {
name: string;
@@ -43,77 +36,17 @@ export const EditableName = ({
name,
onNameSave,
}: EditableTextProps) => {
- const previousName = usePrevious(name);
- const [editableName, setEditableName] = useState(name);
- const [validationError, setValidationError] = useState(null);
- const inputRef = useRef(null);
-
- const { normalizeName, validateName } = useNameEditor({
+ const validateName = useValidateEntityName({
entityName: name,
});
- const exitWithoutSaving = useCallback(() => {
- exitEditing();
- setEditableName(name);
- setValidationError(null);
- }, [exitEditing, name]);
-
- const validate = useCallback(
- (name: string) => {
- const nameError = validateName(name);
-
- if (nameError === null) {
- setValidationError(null);
- } else {
- setValidationError(nameError);
- }
-
- return nameError;
- },
- [validateName],
- );
-
- const attemptSave = useCallback(() => {
- const nameError = validate(editableName);
-
- if (editableName === name) {
- // No change detected
- exitWithoutSaving();
- } else if (nameError === null) {
- // Save the new name
- exitEditing();
- onNameSave(editableName);
- } else {
- // Exit edit mode and revert name
- exitWithoutSaving();
- }
- }, [
+ const [
+ inputRef,
editableName,
- exitEditing,
- exitWithoutSaving,
- name,
- onNameSave,
- validate,
- ]);
-
- const handleKeyUp = useEventCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- attemptSave();
- } else if (e.key === "Escape") {
- exitWithoutSaving();
- }
- },
- );
-
- const handleTitleChange = useEventCallback(
- (e: React.ChangeEvent) => {
- const value = normalizeName(e.target.value);
-
- setEditableName(value);
- validate(value);
- },
- );
+ validationError,
+ handleKeyUp,
+ handleTitleChange,
+ ] = useEditableText(isEditing, name, exitEditing, validateName, onNameSave);
const inputProps = useMemo(
() => ({
@@ -126,43 +59,6 @@ export const EditableName = ({
[handleKeyUp, handleTitleChange, inputTestId],
);
- useEventListener(
- "focusout",
- function handleFocusOut() {
- const input = inputRef.current;
-
- if (input) {
- attemptSave();
- }
- },
- inputRef,
- );
-
- useEffect(
- function syncEditableTitle() {
- if (!isEditing && previousName !== name) {
- setEditableName(name);
- }
- },
- [name, previousName, isEditing],
- );
-
- // TODO: This is a temporary fix to focus the input after context retention applies focus to its target
- // this is a nasty hack to re-focus the input after context retention applies focus to its target
- // this will be addressed in a future task, likely by a focus retention modification
- useEffect(
- function recaptureFocusInEventOfFocusRetention() {
- const input = inputRef.current;
-
- if (isEditing && input) {
- setTimeout(() => {
- input.focus();
- }, 200);
- }
- },
- [isEditing],
- );
-
return (
<>
{isLoading ? : icon}
diff --git a/app/client/src/IDE/Components/EditableName/useNameEditor.ts b/app/client/src/IDE/Components/EditableName/useValidateEntityName.ts
similarity index 74%
rename from app/client/src/IDE/Components/EditableName/useNameEditor.ts
rename to app/client/src/IDE/Components/EditableName/useValidateEntityName.ts
index cae166cab481..bbccf88991b2 100644
--- a/app/client/src/IDE/Components/EditableName/useNameEditor.ts
+++ b/app/client/src/IDE/Components/EditableName/useValidateEntityName.ts
@@ -1,3 +1,4 @@
+import { useCallback } from "react";
import {
ACTION_INVALID_NAME_ERROR,
ACTION_NAME_CONFLICT_ERROR,
@@ -6,18 +7,17 @@ import {
import { shallowEqual, useSelector } from "react-redux";
import type { AppState } from "ee/reducers";
import { getUsedActionNames } from "selectors/actionSelectors";
-import { isNameValid, removeSpecialChars } from "utils/helpers";
-import { useCallback } from "react";
+import { isNameValid } from "utils/helpers";
-interface UseNameEditorProps {
+interface UseValidateEntityNameProps {
entityName: string;
nameErrorMessage?: (name: string) => string;
}
/**
- * Provides a unified way to validate and save entity names.
+ * Provides a unified way to validate entity names.
*/
-export function useNameEditor(props: UseNameEditorProps) {
+export function useValidateEntityName(props: UseValidateEntityNameProps) {
const { entityName, nameErrorMessage = ACTION_NAME_CONFLICT_ERROR } = props;
const usedEntityNames = useSelector(
@@ -25,7 +25,7 @@ export function useNameEditor(props: UseNameEditorProps) {
shallowEqual,
);
- const validateName = useCallback(
+ return useCallback(
(name: string): string | null => {
if (!name || name.trim().length === 0) {
return createMessage(ACTION_INVALID_NAME_ERROR);
@@ -37,9 +37,4 @@ export function useNameEditor(props: UseNameEditorProps) {
},
[entityName, nameErrorMessage, usedEntityNames],
);
-
- return {
- validateName,
- normalizeName: removeSpecialChars,
- };
}
diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx
index 672c1bf366c5..30d1b12546d7 100644
--- a/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx
+++ b/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx
@@ -57,7 +57,7 @@ export function EditableTab(props: EditableTabProps) {
dispatch(saveEntityName({ params: { id, name }, segment, entity }));
exitEditMode();
},
- [entity, exitEditMode, id, segment],
+ [dispatch, entity, exitEditMode, id, segment],
);
return (
diff --git a/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.test.tsx b/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.test.tsx
index 2ed6fad85a83..fba4513791f0 100644
--- a/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.test.tsx
+++ b/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.test.tsx
@@ -46,23 +46,25 @@ describe("DataSidePane", () => {
});
expect(screen.getByText("Databases")).toBeInTheDocument();
- expect(screen.getByText("Products")).toBeInTheDocument();
- expect(screen.getByText("Users")).toBeInTheDocument();
- const usersDSParentElement =
- screen.getByText("Users").parentElement?.parentElement;
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
- expect(usersDSParentElement).toHaveTextContent("2 queries in this app");
-
- const productsDSParentElement =
- screen.getByText("Products").parentElement?.parentElement;
-
- expect(productsDSParentElement).toHaveTextContent("No queries in this app");
+ expect(screen.getAllByRole("listitem")[0].textContent).toContain(
+ "Products",
+ );
+ expect(screen.getAllByRole("listitem")[0].textContent).toContain(
+ "No queries in this app",
+ );
- const ortdersDSParentElement =
- screen.getByText("Orders").parentElement?.parentElement;
+ expect(screen.getAllByRole("listitem")[1].textContent).toContain("Users");
+ expect(screen.getAllByRole("listitem")[1].textContent).toContain(
+ "2 queries in this app",
+ );
- expect(ortdersDSParentElement).toHaveTextContent("1 queries in this app");
+ expect(screen.getAllByRole("listitem")[2].textContent).toContain("Orders");
+ expect(screen.getAllByRole("listitem")[2].textContent).toContain(
+ "1 queries in this app",
+ );
});
it("it uses the selector dsUsageSelector passed as prop", () => {
@@ -83,21 +85,22 @@ describe("DataSidePane", () => {
});
expect(screen.getByText("Databases")).toBeInTheDocument();
- expect(screen.getByText("Products")).toBeInTheDocument();
- expect(screen.getByText("Users")).toBeInTheDocument();
- const usersDSParentElement =
- screen.getByText("Users").parentElement?.parentElement;
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
- expect(usersDSParentElement).toHaveTextContent(
- "Rendering description for users",
+ expect(screen.getAllByRole("listitem")[0].textContent).toContain(
+ "Products",
);
-
- const productsDSParentElement =
- screen.getByText("Products").parentElement?.parentElement;
-
- expect(productsDSParentElement).toHaveTextContent(
+ expect(screen.getAllByRole("listitem")[0].textContent).toContain(
"Rendering description for products",
);
+
+ expect(screen.getAllByRole("listitem")[1].textContent).toContain("Users");
+ expect(screen.getAllByRole("listitem")[1].textContent).toContain(
+ "Rendering description for users",
+ );
+
+ // No description for orders as not passed in by the selector
+ expect(screen.getAllByRole("listitem")[2].textContent).toEqual("Orders");
});
});
diff --git a/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.tsx b/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.tsx
index f5f644f19ef3..a2101d0242d6 100644
--- a/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.tsx
+++ b/app/client/src/pages/Editor/IDE/LeftPane/DataSidePane.tsx
@@ -41,7 +41,6 @@ const PaneBody = styled.div`
const DatasourceIcon = styled.img`
height: 16px;
width: 16px;
- align-self: flex-start;
`;
const StyledList = styled(List)`