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 = ( +