feat: editable dismissible tab component (#38788)

## Description
Addition of a template component that combines editable entity name and
dismissible tab.

Fixes #37649  

## Automation

/ok-to-test tags="@tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!CAUTION]
> 🔴 🔴 🔴 Some tests have failed.
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/12925482595>
> Commit: 23c8fbe877a2390ec95877ae8f761d7c590b23c7
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=12925482595&attempt=2&selectiontype=test&testsstatus=failed&specsstatus=fail"
target="_blank">Cypress dashboard</a>.
> Tags: @tag.Sanity
> Spec: 
> The following are new failures, please fix them before merging the PR:
<ol>
>
<li>cypress/e2e/Regression/ClientSide/OtherUIFeatures/GlobalSearch_spec.js</ol>
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/identified-flaky-tests-65890b3c81d7400d08fa9ee3?branch=master"
target="_blank">List of identified flaky tests</a>.
> <hr>Thu, 23 Jan 2025 11:42:24 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced `DismissibleTab` component with interactive close and click
functionality.
- Added `EditableEntityName` component for editing entity names with
validation.
- Created `EditableDismissibleTab` component combining dismissible and
editable behaviors.
- Added new Storybook stories for `DismissibleTab`,
`EditableDismissibleTab`, and `EditableEntityName` components.

- **Improvements**
- Enhanced design system with new styled components for better
interactivity and appearance.

- **Refactoring**
	- Reorganized hook and component imports.
- Updated export statements in various files to improve module
accessibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Alex 2025-01-23 15:21:05 +03:00 committed by GitHub
parent 72251cba2e
commit efceb1e390
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 429 additions and 9 deletions

View File

@ -0,0 +1,21 @@
/* eslint-disable no-console */
import type { Meta, StoryObj } from "@storybook/react";
import { DismissibleTab } from ".";
const meta: Meta<typeof DismissibleTab> = {
title: "ADS/Components/Dismissible Tab",
component: DismissibleTab,
};
export default meta;
type Story = StoryObj<typeof DismissibleTab>;
export const Basic: Story = {
args: {
isActive: true,
dataTestId: "t--dismissible-tab",
children: "Dismissible tab",
},
};

View File

@ -0,0 +1,55 @@
import styled from "styled-components";
import { Button as ADSButton } from "..";
export const Tab = styled.div`
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: var(--ads-v2-spaces-2);
height: 100%;
font-size: 12px;
color: var(--ads-v2-color-fg);
cursor: pointer;
border-top-left-radius: var(--ads-v2-border-radius);
border-top-right-radius: var(--ads-v2-border-radius);
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-top: 3px solid transparent;
padding: var(--ads-v2-spaces-3);
padding-top: 6px;
&.active {
background: var(--ads-v2-colors-control-field-default-bg);
border-top-color: var(--ads-v2-color-bg-brand);
border-left-color: var(--ads-v2-color-border-muted);
border-right-color: var(--ads-v2-color-border-muted);
span {
font-weight: var(--ads-v2-font-weight-bold);
}
}
& > .tab-close {
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover > .tab-close,
&:focus-within > .tab-close,
&.active > .tab-close {
opacity: 1;
}
`;
export const CloseButton = styled(ADSButton)`
border-radius: 2px;
cursor: pointer;
padding: var(--ads-v2-spaces-1);
max-width: 16px;
max-height: 16px;
`;

View File

@ -0,0 +1,43 @@
import React from "react";
import clsx from "classnames";
import { Icon } from "..";
import * as Styled from "./DismissibleTab.styles";
import { DATA_TEST_ID } from "./constants";
import type { DismissibleTabProps } from "./DismissibleTab.types";
export const DismissibleTab = ({
children,
dataTestId,
isActive,
onClick,
onClose,
onDoubleClick,
}: DismissibleTabProps) => {
return (
<Styled.Tab
className={clsx("editor-tab", isActive && "active")}
data-testid={dataTestId}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{children}
<Styled.CloseButton
aria-label="Close tab"
className="tab-close"
data-testid={DATA_TEST_ID.CLOSE_BUTTON}
isIconButton
kind="tertiary"
onClick={onClose}
role="tab"
size="sm"
tabIndex={0}
>
<Icon name="close-line" />
</Styled.CloseButton>
</Styled.Tab>
);
};

View File

@ -0,0 +1,10 @@
import type React from "react";
export interface DismissibleTabProps {
children: React.ReactNode;
dataTestId?: string;
isActive: boolean;
onClick: () => void;
onClose: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}

View File

@ -0,0 +1,3 @@
export const DATA_TEST_ID = {
CLOSE_BUTTON: "t--tab-close-btn",
};

View File

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

View File

@ -0,0 +1,53 @@
/* eslint-disable no-console */
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { EditableDismissibleTab } from ".";
import styled from "styled-components";
import { Icon } from "../..";
const meta: Meta<typeof EditableDismissibleTab> = {
title: "ADS/Templates/Editable Dismissible Tab",
component: EditableDismissibleTab,
};
const EntityIcon = styled.div`
height: 18px;
width: 18px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
svg,
img {
height: 100%;
width: 100%;
}
`;
const JSIcon = () => {
return (
<EntityIcon>
<Icon name="js-yellow" size="md" />
</EntityIcon>
);
};
export default meta;
type Story = StoryObj<typeof EditableDismissibleTab>;
export const Basic: Story = {
args: {
isActive: true,
dataTestId: "t--dismissible-tab",
icon: JSIcon(),
name: "Hello",
onNameSave: console.log,
validateName: (name: string) =>
name.length < 3 ? "Name must be at least 3 characters" : null,
},
};

View File

@ -0,0 +1,51 @@
import React from "react";
import { noop } from "lodash";
import { useBoolean } from "usehooks-ts";
import { DismissibleTab } from "../..";
import { EditableEntityName } from "..";
import type { EditableDismissibleTabProps } from "./EditableDismissibleTab.types";
export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
const {
dataTestId,
icon,
isActive,
isEditable = true,
isLoading,
name,
onClick,
onClose,
onNameSave,
validateName,
} = props;
const {
setFalse: exitEditMode,
setTrue: enterEditMode,
value: isEditing,
} = useBoolean(false);
const handleDoubleClick = isEditable ? enterEditMode : noop;
return (
<DismissibleTab
dataTestId={dataTestId}
isActive={isActive}
onClick={onClick}
onClose={onClose}
onDoubleClick={handleDoubleClick}
>
<EditableEntityName
icon={icon}
isEditing={isEditing}
isLoading={isLoading}
name={name}
onExitEditing={exitEditMode}
onNameSave={onNameSave}
validateName={validateName}
/>
</DismissibleTab>
);
};

View File

@ -0,0 +1,14 @@
import type React from "react";
export interface EditableDismissibleTabProps {
dataTestId?: string;
icon: React.ReactNode;
isActive: boolean;
isEditable?: boolean;
isLoading: boolean;
name: string;
onClick: () => void;
onClose: () => void;
onNameSave: (name: string) => void;
validateName: (name: string) => string | null;
}

View File

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

View File

@ -0,0 +1,53 @@
/* eslint-disable no-console */
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import styled from "styled-components";
import { Icon } from "../..";
import { EditableEntityName } from ".";
const EntityIcon = styled.div`
height: 18px;
width: 18px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
svg,
img {
height: 100%;
width: 100%;
}
`;
const JSIcon = () => {
return (
<EntityIcon>
<Icon name="js-yellow" size="md" />
</EntityIcon>
);
};
const meta: Meta<typeof EditableEntityName> = {
title: "ADS/Templates/Editable Entity Name",
component: EditableEntityName,
};
export default meta;
type Story = StoryObj<typeof EditableEntityName>;
export const Basic: Story = {
args: {
name: "Hello",
onNameSave: console.log,
onExitEditing: console.log,
icon: JSIcon(),
inputTestId: "t--editable-name",
isEditing: true,
isLoading: false,
validateName: (name: string) =>
name.length < 3 ? "Name must be at least 3 characters" : null,
},
};

View File

@ -0,0 +1,29 @@
import styled from "styled-components";
import { Text as ADSText } from "../../Text";
export const Root = styled.div`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: start;
align-items: center;
gap: var(--ads-v2-spaces-2);
`;
export const Text = styled(ADSText)`
min-width: 3ch;
bottom: -0.5px;
`;
export const IconContainer = styled.div`
height: 12px;
width: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 12px;
}
`;

View File

@ -0,0 +1,64 @@
import React, { useMemo } from "react";
import { Spinner, Tooltip } from "../..";
import { useEditableText } from "../../__hooks__";
import * as Styled from "./EditableEntityName.styles";
import type { EditableEntityNameProps } from "./EditableEntityName.types";
export const EditableEntityName = ({
icon,
inputTestId,
isEditing,
isLoading = false,
name,
onExitEditing,
onNameSave,
validateName,
}: EditableEntityNameProps) => {
const [
inputRef,
editableName,
validationError,
handleKeyUp,
handleTitleChange,
] = useEditableText(isEditing, name, onExitEditing, validateName, onNameSave);
const inputProps = useMemo(
() => ({
["data-testid"]: inputTestId,
onKeyUp: handleKeyUp,
onChange: handleTitleChange,
autoFocus: true,
style: {
paddingTop: 4,
paddingBottom: 4,
left: -1,
top: -5,
},
}),
[handleKeyUp, handleTitleChange, inputTestId],
);
return (
<Styled.Root>
{isLoading ? (
<Spinner size="sm" />
) : (
<Styled.IconContainer>{icon}</Styled.IconContainer>
)}
<Tooltip content={validationError} visible={Boolean(validationError)}>
<Styled.Text
aria-invalid={Boolean(validationError)}
inputProps={inputProps}
inputRef={inputRef}
isEditable={isEditing}
kind="body-s"
>
{editableName}
</Styled.Text>
</Tooltip>
</Styled.Root>
);
};

View File

@ -0,0 +1,12 @@
import type React from "react";
export interface EditableEntityNameProps {
icon: React.ReactNode;
inputTestId?: string;
isEditing: boolean;
isLoading?: boolean;
name: string;
onExitEditing: () => void;
onNameSave: (name: string) => void;
validateName: (name: string) => string | null;
}

View File

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

View File

@ -5,7 +5,7 @@ import { Tooltip } from "../../../Tooltip";
import type { EntityItemProps } from "./EntityItem.types";
import { EntityEditableName } from "./EntityItem.styles";
import { useEditableText } from "../Editable";
import { useEditableText } from "../../../__hooks__/useEditableText";
import clx from "classnames";
export const EntityItem = (props: EntityItemProps) => {

View File

@ -6,6 +6,6 @@ export { EmptyState } from "./EmptyState";
export { NoSearchResults } from "./NoSearchResults";
export * from "./ExplorerContainer";
export * from "./EntityItem";
export { useEditableText } from "./Editable";
export { useEditableText } from "../../__hooks__/useEditableText";
export * from "./EntityGroupsList";
export * from "./EntityListTree";

View File

@ -1,3 +1,5 @@
export * from "./IDEHeader";
export * from "./EntityExplorer";
export * from "./Sidebar";
export * from "./EditableEntityName";
export * from "./EditableDismissibleTab";

View File

@ -0,0 +1,2 @@
export { useDOMRef } from "./useDomRef";
export { useEditableText } from "./useEditableText";

View File

@ -1,8 +1,9 @@
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 "../../..";
import { Text } from "../..";
import { useEditableText } from "./useEditableText";
describe("useEditableText", () => {
const mockExitEditing = jest.fn();

View File

@ -7,8 +7,10 @@ import {
useRef,
type RefObject,
} from "react";
import { usePrevious } from "@mantine/hooks";
import { useEventCallback, useEventListener } from "usehooks-ts";
import { normaliseName } from "./utils";
export function useEditableText(

View File

@ -1,14 +1,16 @@
import "./__theme__/default/index.css";
export * from "./AnnouncementPopover";
export * from "./AnnouncementModal";
export * from "./AnnouncementPopover";
export * from "./Avatar";
export * from "./Button";
export * from "./Badge";
export * from "./Banner";
export * from "./Button";
export * from "./Callout";
export * from "./Checkbox";
export * from "./Collapsible";
export * from "./DatePicker";
export * from "./DismissibleTab";
export * from "./Divider";
export * from "./Flex";
export * from "./FormControl";
@ -31,10 +33,9 @@ export * from "./Switch";
export * from "./Tab";
export * from "./Table";
export * from "./Tag";
export * from "./Templates";
export * from "./Text";
export * from "./Toast";
export * from "./ToggleButton";
export * from "./Tooltip";
export * from "./ToggleButtonGroup";
export * from "./Templates";
export * from "./Badge";
export * from "./Tooltip";