feat: editable ide tabs (#36665)

## Description
Adds editable tab behavior for queries and JS objects.

Fixes #32440

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

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/11288430138>
> Commit: dc89acbd51afc6b238283836c6305ab68337575d
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=11288430138&attempt=3"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Fri, 11 Oct 2024 09:55:27 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

## Release Notes

- **New Features**
	- Introduced a new dependency for improved React hooks functionality.
- Added new constants for data source operations and user interactions,
enhancing feedback and clarity.

- **Improvements**
- Enhanced messaging related to data source actions, providing clearer
user prompts and error handling messages.
- Renamed constants for better readability and consistency across the
application.

- **Bug Fixes**
- Corrected naming conventions for constants to improve consistency
across the application.

- **Chores**
- Removed deprecated `FileTabs` component and related tests to
streamline the codebase.
- Added unit tests for the `FileTab` component to ensure expected
functionality.
- Updated the `Text` component to improve ref handling and styling
options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Alex 2024-10-14 11:05:38 +03:00 committed by GitHub
parent a7bf302f9a
commit 299cc42aeb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 744 additions and 317 deletions

View File

@ -17,7 +17,7 @@ module.exports = {
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node", "css"], moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node", "css"],
moduleDirectories: ["node_modules", "src", "test"], moduleDirectories: ["node_modules", "src", "test"],
transformIgnorePatterns: [ transformIgnorePatterns: [
"<rootDir>/node_modules/(?!codemirror|konva|react-dnd|dnd-core|@babel|(@blueprintjs)|@github|lodash-es|@draft-js-plugins|react-documents|linkedom|assert-never|axios)", "<rootDir>/node_modules/(?!codemirror|konva|react-dnd|dnd-core|@babel|(@blueprintjs)|@github|lodash-es|@draft-js-plugins|react-documents|linkedom|assert-never|axios|usehooks-ts)",
], ],
moduleNameMapper: { moduleNameMapper: {
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js", "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",

View File

@ -227,6 +227,7 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"unescape-js": "^1.1.4", "unescape-js": "^1.1.4",
"url-search-params-polyfill": "^8.0.0", "url-search-params-polyfill": "^8.0.0",
"usehooks-ts": "^3.1.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"validate-color": "^2.2.4", "validate-color": "^2.2.4",
"web-vitals": "3.5.2", "web-vitals": "3.5.2",

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useMemo } from "react";
import { Text } from "./Text"; import { Text } from "./Text";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
@ -25,15 +25,17 @@ export const EditableTextStory: Story = {
}, },
render: function Render(args) { render: function Render(args) {
const [text, setText] = React.useState(args.children); const [text, setText] = React.useState(args.children);
const inputProps = useMemo(
() => ({
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
},
}),
[],
);
return ( return (
<Text <Text inputProps={inputProps} {...args}>
{...args}
onChange={(e) => {
// @ts-expect-error type error
setText(e.target.value);
}}
>
{text} {text}
</Text> </Text>
); );

View File

@ -125,7 +125,7 @@ export const StyledText = styled.span<{
isBold?: boolean; isBold?: boolean;
isItalic?: boolean; isItalic?: boolean;
isUnderlined?: boolean; isUnderlined?: boolean;
isStriked?: boolean; isStrikethrough?: boolean;
isEditable?: boolean; isEditable?: boolean;
}>` }>`
${TypographyScales} ${TypographyScales}
@ -160,8 +160,8 @@ export const StyledText = styled.span<{
text-decoration: underline; text-decoration: underline;
} }
/* Striked style */ /* Strikethrough style */
&[data-striked="true"] { &[data-strikethrough="true"] {
text-decoration: line-through; text-decoration: line-through;
} }
@ -191,10 +191,8 @@ export const StyledEditableInput = styled.input`
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--ads-v2-border-radius); border-radius: var(--ads-v2-border-radius);
outline: none; outline: none;
padding: 0;
margin: 0; margin: 0;
position: absolute; position: absolute;
left: -3px;
top: -3px; top: -3px;
width: 100%; width: 100%;
padding: var(--ads-v2-spaces-1); padding: var(--ads-v2-spaces-1);
@ -205,6 +203,6 @@ export const StyledEditableInput = styled.input`
&:focus, &:focus,
&:active { &:active {
border-color: var(--ads-v2-colors-control-field-default-border); border-color: var(--ads-v2-colors-control-field-active-border);
} }
`; `;

View File

@ -1,6 +1,6 @@
import React from "react"; import React, { forwardRef } from "react";
import clsx from "classnames"; import clsx from "classnames";
import type { TextProps } from "./Text.types"; import type { RenderAsElement, TextProps } from "./Text.types";
import { StyledEditableInput, StyledText } from "./Text.styles"; import { StyledEditableInput, StyledText } from "./Text.styles";
import { TextClassName } from "./Text.constants"; import { TextClassName } from "./Text.constants";
@ -9,17 +9,20 @@ TODO:
- add segment header style to list of styles - add segment header style to list of styles
*/ */
function Text({ const Text = forwardRef<RenderAsElement, TextProps>(function Text(
children, {
className, children,
color, className,
inputProps, color,
isEditable, inputProps,
kind, inputRef,
onChange, isEditable,
renderAs, kind,
...rest renderAs,
}: TextProps) { ...rest
}: TextProps,
ref,
) {
return ( return (
<StyledText <StyledText
as={renderAs} as={renderAs}
@ -27,31 +30,28 @@ function Text({
color={color} color={color}
data-bold={rest.isBold} data-bold={rest.isBold}
data-italic={rest.isItalic} data-italic={rest.isItalic}
data-striked={rest.isStriked} data-strikethrough={rest.isStrikethrough}
data-underlined={rest.isUnderlined} data-underlined={rest.isUnderlined}
data-value={isEditable && typeof children === "string" ? children : null} data-value={isEditable && typeof children === "string" ? children : null}
isEditable={isEditable && typeof children === "string"} isEditable={isEditable && typeof children === "string"}
kind={kind} kind={kind}
ref={ref}
{...rest} {...rest}
> >
{isEditable && typeof children === "string" ? ( {isEditable && typeof children === "string" ? (
<StyledEditableInput <StyledEditableInput ref={inputRef} value={children} {...inputProps} />
onChange={onChange}
value={children}
{...inputProps}
/>
) : ( ) : (
children children
)} )}
</StyledText> </StyledText>
); );
} });
Text.displayName = "Text"; Text.displayName = "Text";
Text.defaultProps = { Text.defaultProps = {
renderAs: "span", renderAs: "span",
kind: "span", kind: "body-m",
}; };
export { Text }; export { Text };

View File

@ -1,6 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type React from "react"; import type React from "react";
/** Text style variant */
export type TextKind = export type TextKind =
| "heading-xl" | "heading-xl"
| "heading-l" | "heading-l"
@ -14,7 +15,14 @@ export type TextKind =
| "action-s" | "action-s"
| "code"; | "code";
// Text props /** All possible element types text can be rendered as, matches renderAs prop */
export type RenderAsElement =
| HTMLHeadingElement
| HTMLLabelElement
| HTMLParagraphElement
| HTMLSpanElement;
/** Text component props */
export type TextProps = { export type TextProps = {
/** to change the rendering component */ /** to change the rendering component */
renderAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "label"; renderAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "label";
@ -32,17 +40,14 @@ export type TextProps = {
isItalic?: boolean; isItalic?: boolean;
/** whether the text is underlined or not */ /** whether the text is underlined or not */
isUnderlined?: boolean; isUnderlined?: boolean;
/** whether the text is striked or not */ /** whether the text is strikethrough or not */
isStriked?: boolean; isStrikethrough?: boolean;
/** whether the text is editable or not */ /** whether the text is editable or not */
isEditable?: boolean; isEditable?: boolean;
/** onChange event for editable text */
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
/** input component props while isEditable is true */ /** input component props while isEditable is true */
inputProps?: Omit< inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value">;
React.InputHTMLAttributes<HTMLInputElement>, /** ref for input component */
"value" | "onChange" inputRef?: React.RefObject<HTMLInputElement>;
>;
} & React.HTMLAttributes<HTMLLabelElement> & } & React.HTMLAttributes<HTMLLabelElement> &
React.HTMLAttributes<HTMLHeadingElement> & React.HTMLAttributes<HTMLHeadingElement> &
React.HTMLAttributes<HTMLParagraphElement> & React.HTMLAttributes<HTMLParagraphElement> &

View File

@ -1,99 +0,0 @@
import React from "react";
import styled from "styled-components";
import clsx from "classnames";
import { Flex, Icon } from "@appsmith/ads";
import { sanitizeString } from "utils/URLUtils";
interface FileTabProps {
isActive: boolean;
title: string;
onClick: () => void;
onClose: (e: React.MouseEvent) => void;
icon?: React.ReactNode;
}
export const StyledTab = styled(Flex)`
position: relative;
height: 100%;
font-size: 12px;
color: var(--ads-v2-colors-text-default);
cursor: pointer;
gap: var(--ads-v2-spaces-2);
border-top: 1px solid transparent;
border-top-left-radius: var(--ads-v2-border-radius);
border-top-right-radius: var(--ads-v2-border-radius);
align-items: center;
justify-content: center;
padding: var(--ads-v2-spaces-3);
padding-top: 6px; // to accomodate border and make icons align correctly
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-top: 2px solid transparent;
&.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);
}
& > .tab-close {
position: relative;
right: -2px;
visibility: hidden;
}
&:hover > .tab-close {
visibility: visible;
}
&.active > .tab-close {
visibility: visible;
}
`;
export const TabTextContainer = styled.span`
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`;
export const TabIconContainer = styled.div`
height: 12px;
width: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 12px;
}
`;
export const FileTab = ({
icon,
isActive,
onClick,
onClose,
title,
}: FileTabProps) => {
return (
<StyledTab
className={clsx("editor-tab", isActive && "active")}
data-testid={`t--ide-tab-${sanitizeString(title)}`}
onClick={onClick}
>
{icon ? <TabIconContainer>{icon}</TabIconContainer> : null}
<TabTextContainer>{title}</TabTextContainer>
{/* not using button component because of the size not matching design */}
<Icon
className="tab-close rounded-[4px] hover:bg-[var(--ads-v2-colors-action-tertiary-surface-hover-bg)] cursor-pointer p-[2px]"
data-testid="t--tab-close-btn"
name="close-line"
onClick={onClose}
/>
</StyledTab>
);
};

View File

@ -0,0 +1,220 @@
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
/* eslint-disable react-perf/jsx-no-jsx-as-prop */
import "@testing-library/jest-dom";
import React from "react";
import { render, fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Icon } from "@appsmith/ads";
import { FileTab } from "./FileTab";
import { DATA_TEST_ID } from "./constants";
describe("FileTab", () => {
const mockOnClick = jest.fn();
const mockOnClose = jest.fn();
const TITLE = "test_file";
const TabIcon = () => <Icon name="js" />;
const KEY_CONFIG = {
ENTER: { key: "Enter", keyCode: 13 },
ESC: { key: "Esc", keyCode: 27 },
};
const setup = (
mockEditorConfig: {
onTitleSave: () => void;
titleTransformer: (title: string) => string;
validateTitle: (title: string) => string | null;
} = {
onTitleSave: jest.fn(),
titleTransformer: jest.fn((title) => title),
validateTitle: jest.fn(() => null),
},
isLoading = false,
) => {
const utils = render(
<FileTab
editorConfig={mockEditorConfig}
icon={<TabIcon />}
isActive
isLoading={isLoading}
onClick={mockOnClick}
onClose={mockOnClose}
title={TITLE}
/>,
);
const tabElement = utils.getByText(TITLE);
return {
tabElement,
...utils,
...mockEditorConfig,
};
};
test("renders component", () => {
const { getByTestId, tabElement } = setup();
fireEvent.click(tabElement);
expect(mockOnClick).toHaveBeenCalled();
const closeButton = getByTestId(DATA_TEST_ID.CLOSE_BUTTON);
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
test("renders component in loading state", () => {
const { getByTestId, tabElement } = setup(undefined, true);
fireEvent.click(tabElement);
expect(mockOnClick).toHaveBeenCalled();
const spinner = getByTestId(DATA_TEST_ID.SPINNER);
fireEvent.click(spinner);
const closeButton = getByTestId(DATA_TEST_ID.CLOSE_BUTTON);
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
test("enters edit mode on double click", () => {
const { getByTestId, tabElement } = setup();
fireEvent.doubleClick(tabElement);
within(tabElement).getByTestId(DATA_TEST_ID.INPUT);
const closeButton = getByTestId(DATA_TEST_ID.CLOSE_BUTTON);
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalled();
});
test("valid title actions", async () => {
const {
getByTestId,
getByText,
onTitleSave,
queryByText,
tabElement,
titleTransformer,
validateTitle,
} = setup();
// hit enter
const enterTitle = "enter_title";
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: enterTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(enterTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ENTER);
expect(titleTransformer).toHaveBeenCalledWith(enterTitle);
expect(validateTitle).toHaveBeenCalledWith(enterTitle);
expect(onTitleSave).toHaveBeenCalledWith(enterTitle);
expect(getByText(enterTitle)).toBeInTheDocument();
// click outside
const clickOutsideTitle = "click_outside_title";
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: clickOutsideTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(clickOutsideTitle);
await userEvent.click(document.body);
expect(titleTransformer).toHaveBeenCalledWith(clickOutsideTitle);
expect(validateTitle).toHaveBeenCalledWith(clickOutsideTitle);
expect(onTitleSave).toHaveBeenCalledWith(clickOutsideTitle);
expect(getByText(clickOutsideTitle)).toBeInTheDocument();
// hit esc
const escapeTitle = "escape_title";
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: escapeTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(escapeTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ESC);
expect(queryByText(escapeTitle)).not.toBeInTheDocument();
expect(getByText(TITLE)).toBeInTheDocument();
// focus out event
const focusOutTitle = "focus_out_title";
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: focusOutTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(focusOutTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ESC);
expect(queryByText(focusOutTitle)).not.toBeInTheDocument();
expect(getByText(TITLE)).toBeInTheDocument();
});
test("invalid title actions", async () => {
const validationError = "Invalid title";
const invalidTitle = "else";
const {
getByTestId,
getByText,
queryByText,
tabElement,
titleTransformer,
validateTitle,
} = setup({
onTitleSave: jest.fn(),
titleTransformer: jest.fn((title) => title),
validateTitle: jest.fn(() => validationError),
});
// click outside
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: invalidTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(invalidTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ENTER);
expect(titleTransformer).toHaveBeenCalledWith(invalidTitle);
expect(validateTitle).toHaveBeenCalledWith(invalidTitle);
expect(getByText(validationError)).toBeInTheDocument();
await userEvent.click(document.body);
expect(queryByText(validationError)).not.toBeInTheDocument();
expect(getByText(TITLE)).toBeInTheDocument();
// escape
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: invalidTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(invalidTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ENTER);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ESC);
expect(queryByText(validationError)).not.toBeInTheDocument();
expect(getByText(TITLE)).toBeInTheDocument();
// focus out event
fireEvent.doubleClick(tabElement);
fireEvent.change(getByTestId(DATA_TEST_ID.INPUT), {
target: { value: invalidTitle },
});
expect(titleTransformer).toHaveBeenCalledWith(invalidTitle);
fireEvent.keyUp(getByTestId(DATA_TEST_ID.INPUT), KEY_CONFIG.ENTER);
fireEvent.focusOut(getByTestId(DATA_TEST_ID.INPUT));
expect(queryByText(validationError)).not.toBeInTheDocument();
expect(getByText(TITLE)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,188 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import clsx from "classnames";
import { noop } from "lodash";
import { Icon, Spinner, Tooltip } from "@appsmith/ads";
import { sanitizeString } from "utils/URLUtils";
import { useBoolean, useEventCallback, useEventListener } from "usehooks-ts";
import { usePrevious } from "@mantine/hooks";
import * as Styled from "./styles";
import { DATA_TEST_ID } from "./constants";
export interface FileTabProps {
isActive: boolean;
isLoading?: boolean;
title: string;
onClick: () => void;
onClose: (e: React.MouseEvent) => void;
icon?: React.ReactNode;
editorConfig?: {
/** Triggered on enter or click outside */
onTitleSave: (name: string) => void;
/** Used to normalize title (remove white spaces etc.) */
titleTransformer: (name: string) => string;
/** Validates title and returns an error message or null */
validateTitle: (name: string) => string | null;
};
}
export const FileTab = ({
editorConfig,
icon,
isActive,
isLoading = false,
onClick,
onClose,
title,
}: FileTabProps) => {
const {
setFalse: exitEditMode,
setTrue: enterEditMode,
value: isEditing,
} = useBoolean(false);
const previousTitle = usePrevious(title);
const [editableTitle, setEditableTitle] = useState(title);
const currentTitle =
isEditing || isLoading || title !== editableTitle ? editableTitle : title;
const [validationError, setValidationError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleKeyUp = useEventCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (editorConfig) {
const { onTitleSave, validateTitle } = editorConfig;
const nameError = validateTitle(editableTitle);
if (nameError === null) {
exitEditMode();
onTitleSave(editableTitle);
} else {
setValidationError(nameError);
}
}
} else if (e.key === "Escape") {
exitEditMode();
setEditableTitle(title);
setValidationError(null);
} else {
setValidationError(null);
}
},
);
const handleTitleChange = useEventCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEditableTitle(
editorConfig
? editorConfig.titleTransformer(e.target.value)
: e.target.value,
);
},
);
const handleEnterEditMode = useEventCallback(() => {
setEditableTitle(title);
enterEditMode();
});
const handleDoubleClick = editorConfig ? handleEnterEditMode : noop;
const inputProps = useMemo(
() => ({
["data-testid"]: DATA_TEST_ID.INPUT,
onKeyUp: handleKeyUp,
onChange: handleTitleChange,
autoFocus: true,
style: {
paddingTop: 0,
paddingBottom: 0,
left: -1,
top: -1,
},
}),
[handleKeyUp, handleTitleChange],
);
useEventListener(
"focusout",
function handleFocusOut() {
if (isEditing && editorConfig) {
const { onTitleSave, validateTitle } = editorConfig;
const nameError = validateTitle(editableTitle);
exitEditMode();
if (nameError === null) {
onTitleSave(editableTitle);
} else {
setEditableTitle(title);
setValidationError(null);
}
}
},
inputRef,
);
useEffect(
function syncEditableTitle() {
if (!isEditing && previousTitle !== title) {
setEditableTitle(title);
}
},
[title, previousTitle, 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 (
<Styled.Tab
className={clsx("editor-tab", isActive && "active")}
data-testid={`t--ide-tab-${sanitizeString(title)}`}
onClick={onClick}
onDoubleClick={handleDoubleClick}
>
{icon && !isLoading ? (
<Styled.IconContainer>{icon}</Styled.IconContainer>
) : null}
{isLoading && <Spinner data-testid={DATA_TEST_ID.SPINNER} size="sm" />}
<Tooltip content={validationError} visible={Boolean(validationError)}>
<Styled.Text
inputProps={inputProps}
inputRef={inputRef}
isEditable={isEditing}
kind="body-s"
>
{currentTitle}
</Styled.Text>
</Tooltip>
<Styled.CloseButton
aria-label="Close tab"
className="tab-close"
data-testid={DATA_TEST_ID.CLOSE_BUTTON}
onClick={onClose}
>
<Icon name="close-line" />
</Styled.CloseButton>
</Styled.Tab>
);
};

View File

@ -0,0 +1,5 @@
export const DATA_TEST_ID = {
INPUT: "t--ide-tab-editable-input",
CLOSE_BUTTON: "t--tab-close-btn",
SPINNER: "t--ide-tab-spinner",
};

View File

@ -0,0 +1,2 @@
export { FileTab } from "./FileTab";
export type { FileTabProps } from "./FileTab";

View File

@ -0,0 +1,68 @@
import styled from "styled-components";
import { Text as ADSText } from "@appsmith/ads";
export const Tab = styled.div`
display: flex;
height: 100%;
position: relative;
font-size: 12px;
color: var(--ads-v2-colors-text-default);
cursor: pointer;
gap: var(--ads-v2-spaces-2);
border-top: 1px solid transparent;
border-top-left-radius: var(--ads-v2-border-radius);
border-top-right-radius: var(--ads-v2-border-radius);
align-items: center;
justify-content: center;
padding: var(--ads-v2-spaces-3);
padding-top: 6px; // to accommodate border and make icons align correctly
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-top: 2px solid transparent;
&.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);
}
& > .tab-close {
position: relative;
right: -2px;
visibility: hidden;
}
&:hover > .tab-close,
&.active > .tab-close {
visibility: visible;
}
`;
export const IconContainer = styled.div`
height: 12px;
width: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 12px;
}
`;
export const Text = styled(ADSText)`
min-width: 3ch;
padding: 0 var(--ads-v2-spaces-1);
`;
export const CloseButton = styled.button`
border-radius: var(--ads-v2-border-radius);
cursor: pointer;
padding: var(--ads-v2-spaces-1);
&:hover {
background: var(--ads-v2-colors-action-tertiary-surface-hover-bg);
}
`;

View File

@ -354,7 +354,7 @@ export const ENTITY_EXPLORER_ACTION_NAME_CONFLICT_ERROR = (name: string) =>
export const ACTION_ID_NOT_FOUND_IN_URL = export const ACTION_ID_NOT_FOUND_IN_URL =
"No correct API id or Query id found in the url."; "No correct API id or Query id found in the url.";
export const JSOBJECT_ID_NOT_FOUND_IN_URL = export const JS_OBJECT_ID_NOT_FOUND_IN_URL =
"No correct JS Object id found in the url."; "No correct JS Object id found in the url.";
export const DATASOURCE_CREATE = (dsName: string) => export const DATASOURCE_CREATE = (dsName: string) =>

View File

@ -0,0 +1,64 @@
import React, { useMemo } from "react";
import { FileTab, type FileTabProps } from "IDE/Components/FileTab";
import { useNameEditor } from "utils/hooks/useNameEditor";
import { EditorEntityTab } from "ee/entities/IDE/constants";
import { saveActionName } from "actions/pluginActionActions";
import { saveJSObjectName } from "actions/jsActionActions";
import { useCurrentEditorState } from "../hooks";
import {
getIsSavingForApiName,
getIsSavingForJSObjectName,
} from "selectors/ui";
import { useSelector } from "react-redux";
import { useEventCallback } from "usehooks-ts";
interface EditableTabProps extends Omit<FileTabProps, "isLoading" | "onClose"> {
id: string;
onClose: (id: string) => void;
}
export function EditableTab(props: EditableTabProps) {
const { icon, id, isActive, onClick, onClose, title } = props;
const { segment } = useCurrentEditorState();
const { handleNameSave, normalizeName, validateName } = useNameEditor({
entityId: id,
entityName: title,
nameSaveAction:
EditorEntityTab.JS === segment ? saveJSObjectName : saveActionName,
});
const isLoading = useSelector((state) =>
EditorEntityTab.JS === segment
? getIsSavingForJSObjectName(state, id)
: getIsSavingForApiName(state, id),
);
const editorConfig = useMemo(
() => ({
onTitleSave: handleNameSave,
validateTitle: validateName,
titleTransformer: normalizeName,
}),
[handleNameSave, normalizeName, validateName],
);
const handleClose = useEventCallback((e: React.MouseEvent) => {
e.stopPropagation();
onClose(id);
});
return (
<FileTab
editorConfig={editorConfig}
icon={icon}
isActive={isActive}
isLoading={isLoading}
onClick={onClick}
onClose={handleClose}
title={title}
/>
);
}

View File

@ -1,85 +0,0 @@
import React from "react";
import { fireEvent, render } from "test/testUtils";
import FileTabs from "./FileTabs";
import { EditorState, type EntityItem } from "ee/entities/IDE/constants";
import { PluginType } from "entities/Action";
import { FocusEntity } from "navigation/FocusEntity";
import { sanitizeString } from "utils/URLUtils";
describe("FileTabs", () => {
const mockTabs: EntityItem[] = [
{ key: "1", title: "File 1", type: PluginType.JS },
{ key: "2", title: "File 2", type: PluginType.JS },
{ key: "3", title: "File 3", type: PluginType.JS },
{ key: "4", title: "File 4", type: PluginType.JS },
];
const mockNavigateToTab = jest.fn();
const mockOnClose = jest.fn();
const activeEntity = {
entity: FocusEntity.API,
id: "File 1",
appState: EditorState.EDITOR,
params: {},
};
it("renders tabs correctly", () => {
const { getByTestId, getByText } = render(
<FileTabs
currentEntity={activeEntity}
navigateToTab={mockNavigateToTab}
onClose={mockOnClose}
tabs={mockTabs}
/>,
);
// Check if each tab is rendered with correct content
mockTabs.forEach((tab) => {
const tabElement = getByTestId(`t--ide-tab-${sanitizeString(tab.title)}`);
expect(tabElement).not.toBeNull();
const tabTitleElement = getByText(tab.title);
expect(tabTitleElement).not.toBeNull();
});
});
it("check tab click", () => {
const { getByTestId } = render(
<FileTabs
currentEntity={activeEntity}
navigateToTab={mockNavigateToTab}
onClose={mockOnClose}
tabs={mockTabs}
/>,
);
const tabElement = getByTestId(
`t--ide-tab-${sanitizeString(mockTabs[0].title)}`,
);
fireEvent.click(tabElement);
expect(mockNavigateToTab).toHaveBeenCalledWith(mockTabs[0]);
});
it("check for close click", () => {
const { getByTestId } = render(
<FileTabs
currentEntity={activeEntity}
navigateToTab={mockNavigateToTab}
onClose={mockOnClose}
tabs={mockTabs}
/>,
);
const tabElement = getByTestId(
`t--ide-tab-${sanitizeString(mockTabs[1].title)}`,
);
const closeElement = tabElement.querySelector(
"[data-testid='t--tab-close-btn']",
) as HTMLElement;
fireEvent.click(closeElement);
expect(mockOnClose).toHaveBeenCalledWith(mockTabs[1].key);
});
});

View File

@ -1,48 +0,0 @@
import React from "react";
import {
EditorEntityTabState,
type EntityItem,
} from "ee/entities/IDE/constants";
import { useCurrentEditorState } from "../hooks";
import { FileTab } from "IDE/Components/FileTab";
import type { FocusEntityInfo } from "navigation/FocusEntity";
interface Props {
tabs: EntityItem[];
navigateToTab: (tab: EntityItem) => void;
onClose: (actionId?: string) => void;
currentEntity: FocusEntityInfo;
isListActive?: boolean;
}
const FileTabs = (props: Props) => {
const { currentEntity, isListActive, navigateToTab, onClose, tabs } = props;
const { segmentMode } = useCurrentEditorState();
const onCloseClick = (e: React.MouseEvent, id?: string) => {
e.stopPropagation();
onClose(id);
};
return (
<>
{tabs.map((tab: EntityItem) => (
<FileTab
icon={tab.icon}
isActive={
currentEntity.id === tab.key &&
segmentMode !== EditorEntityTabState.Add &&
!isListActive
}
key={tab.key}
onClick={() => navigateToTab(tab)}
onClose={(e) => onCloseClick(e, tab.key)}
title={tab.title}
/>
))}
</>
);
};
export default FileTabs;

View File

@ -37,3 +37,10 @@ export const TabSelectors: Record<
itemUrlSelector: () => "", itemUrlSelector: () => "",
}, },
}; };
export const SCROLL_AREA_OPTIONS = {
overflow: {
x: "scroll",
y: "hidden",
},
} as const;

View File

@ -12,10 +12,10 @@ import {
EditorEntityTabState, EditorEntityTabState,
EditorViewMode, EditorViewMode,
} from "ee/entities/IDE/constants"; } from "ee/entities/IDE/constants";
import FileTabs from "./FileTabs";
import Container from "./Container"; import Container from "./Container";
import { useCurrentEditorState, useIDETabClickHandlers } from "../hooks"; import { useCurrentEditorState, useIDETabClickHandlers } from "../hooks";
import { TabSelectors } from "./constants"; import { SCROLL_AREA_OPTIONS, TabSelectors } from "./constants";
import { AddButton } from "./AddButton"; import { AddButton } from "./AddButton";
import { Announcement } from "../EditorPane/components/Announcement"; import { Announcement } from "../EditorPane/components/Announcement";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
@ -25,6 +25,10 @@ import { ScreenModeToggle } from "./ScreenModeToggle";
import { AddTab } from "./AddTab"; import { AddTab } from "./AddTab";
import { setListViewActiveState } from "actions/ideActions"; import { setListViewActiveState } from "actions/ideActions";
import { useEventCallback } from "usehooks-ts";
import { EditableTab } from "./EditableTab";
const EditorTabs = () => { const EditorTabs = () => {
const isSideBySideEnabled = useSelector(getIsSideBySideEnabled); const isSideBySideEnabled = useSelector(getIsSideBySideEnabled);
const ideViewMode = useSelector(getIDEViewMode); const ideViewMode = useSelector(getIDEViewMode);
@ -41,21 +45,21 @@ const EditorTabs = () => {
// Turn off list view while changing segment, files // Turn off list view while changing segment, files
useEffect(() => { useEffect(() => {
dispatch(setListViewActiveState(false)); dispatch(setListViewActiveState(false));
}, [currentEntity.id, currentEntity.entity, files, segmentMode]); }, [currentEntity.id, currentEntity.entity, files, segmentMode, dispatch]);
// Show list view if all tabs is closed // Show list view if all tabs is closed
useEffect(() => { useEffect(() => {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) { if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) {
dispatch(setListViewActiveState(true)); dispatch(setListViewActiveState(true));
} }
}, [files, segmentMode, currentEntity.entity]); }, [files, segmentMode, currentEntity.entity, dispatch]);
// scroll to the active tab // scroll to the active tab
useEffect(() => { useEffect(() => {
const activetab = document.querySelector(".editor-tab.active"); const activeTab = document.querySelector(".editor-tab.active");
if (activetab) { if (activeTab) {
activetab.scrollIntoView({ activeTab.scrollIntoView({
inline: "nearest", inline: "nearest",
}); });
} }
@ -74,24 +78,25 @@ const EditorTabs = () => {
} }
}, [files]); }, [files]);
if (!isSideBySideEnabled) return null; const handleHamburgerClick = useEventCallback(() => {
if (segment === EditorEntityTab.UI) return null;
const handleHamburgerClick = () => {
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return; if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return;
dispatch(setListViewActiveState(!isListViewActive)); dispatch(setListViewActiveState(!isListViewActive));
}; });
const onTabClick = (tab: EntityItem) => { // TODO: this returns a new function every time, needs to be recomposed
const handleTabClick = useEventCallback((tab: EntityItem) => () => {
dispatch(setListViewActiveState(false)); dispatch(setListViewActiveState(false));
tabClickHandler(tab); tabClickHandler(tab);
}; });
const newTabClickHandler = () => { const handleNewTabClick = useEventCallback(() => {
dispatch(setListViewActiveState(false)); dispatch(setListViewActiveState(false));
}; });
if (!isSideBySideEnabled) return null;
if (segment === EditorEntityTab.UI) return null;
return ( return (
<> <>
@ -108,13 +113,8 @@ const EditorTabs = () => {
<ScrollArea <ScrollArea
className="h-[32px] top-[0.5px]" className="h-[32px] top-[0.5px]"
data-testid="t--editor-tabs" data-testid="t--editor-tabs"
options={{ options={SCROLL_AREA_OPTIONS}
overflow: { size="sm"
x: "scroll",
y: "hidden",
},
}}
size={"sm"}
> >
<Flex <Flex
className="items-center" className="items-center"
@ -122,21 +122,28 @@ const EditorTabs = () => {
gap="spaces-2" gap="spaces-2"
height="100%" height="100%"
> >
<FileTabs {files.map((tab) => (
currentEntity={currentEntity} <EditableTab
isListActive={isListViewActive} icon={tab.icon}
navigateToTab={onTabClick} id={tab.key}
onClose={closeClickHandler} isActive={
tabs={files} currentEntity.id === tab.key &&
/> segmentMode !== EditorEntityTabState.Add &&
!isListViewActive
}
key={tab.key}
onClick={handleTabClick(tab)}
onClose={closeClickHandler}
title={tab.title}
/>
))}
<AddTab <AddTab
isListActive={isListViewActive} isListActive={isListViewActive}
newTabClickCallback={newTabClickHandler} newTabClickCallback={handleNewTabClick}
onClose={closeClickHandler} onClose={closeClickHandler}
/> />
</Flex> </Flex>
</ScrollArea> </ScrollArea>
{files.length > 0 ? <AddButton /> : null} {files.length > 0 ? <AddButton /> : null}
{/* Switch screen mode button */} {/* Switch screen mode button */}
<ScreenModeToggle /> <ScreenModeToggle />

View File

@ -180,11 +180,13 @@ export const useIDETabClickHandlers = () => {
(item: EntityItem) => { (item: EntityItem) => {
const navigateToUrl = tabsConfig.itemUrlSelector(item, basePageId); const navigateToUrl = tabsConfig.itemUrlSelector(item, basePageId);
history.push(navigateToUrl, { if (navigateToUrl !== history.location.pathname) {
invokedBy: NavigationMethod.EditorTabs, history.push(navigateToUrl, {
}); invokedBy: NavigationMethod.EditorTabs,
});
}
}, },
[segment, basePageId], [tabsConfig, basePageId],
); );
const closeClickHandler = useCallback( const closeClickHandler = useCallback(

View File

@ -10,7 +10,7 @@ import {
} from "ee/selectors/entitiesSelector"; } from "ee/selectors/entitiesSelector";
import { import {
ACTION_NAME_PLACEHOLDER, ACTION_NAME_PLACEHOLDER,
JSOBJECT_ID_NOT_FOUND_IN_URL, JS_OBJECT_ID_NOT_FOUND_IN_URL,
createMessage, createMessage,
} from "ee/constants/messages"; } from "ee/constants/messages";
import EditableText, { import EditableText, {
@ -62,7 +62,7 @@ export function JSObjectNameEditor(props: JSObjectNameEditorProps) {
return ( return (
<NameEditorComponent <NameEditorComponent
id={currentJSObjectConfig?.id} id={currentJSObjectConfig?.id}
idUndefinedErrorMessage={JSOBJECT_ID_NOT_FOUND_IN_URL} idUndefinedErrorMessage={JS_OBJECT_ID_NOT_FOUND_IN_URL}
name={currentJSObjectConfig?.name} name={currentJSObjectConfig?.name}
onSaveName={props.saveJSObjectName} onSaveName={props.saveJSObjectName}
saveStatus={saveStatus} saveStatus={saveStatus}

View File

@ -22,6 +22,10 @@ export const getDefaultSelectedWidgetIds = (state: AppState) => {
export const getIsSavingForApiName = (state: AppState, id: string) => export const getIsSavingForApiName = (state: AppState, id: string) =>
state.ui.apiName.isSaving[id]; state.ui.apiName.isSaving[id];
/** Select saving status for all API names */
export const getApiNameSavingStatuses = (state: AppState) =>
state.ui.apiName.isSaving;
/** /**
* Selector to use id and provide the status of error in an API. * Selector to use id and provide the status of error in an API.
*/ */
@ -34,6 +38,10 @@ export const getErrorForApiName = (state: AppState, id: string) =>
export const getIsSavingForJSObjectName = (state: AppState, id: string) => export const getIsSavingForJSObjectName = (state: AppState, id: string) =>
state.ui.jsObjectName.isSaving[id]; state.ui.jsObjectName.isSaving[id];
/** Select saving status for all JS object names */
export const getJsObjectNameSavingStatuses = (state: AppState) =>
state.ui.jsObjectName.isSaving;
/** /**
* Selector to use id and provide the status of error in a JS Object. * Selector to use id and provide the status of error in a JS Object.
*/ */

View File

@ -0,0 +1,70 @@
import { useSelector, useDispatch, shallowEqual } from "react-redux";
import { isNameValid, removeSpecialChars } from "utils/helpers";
import type { AppState } from "ee/reducers";
import { getUsedActionNames } from "selectors/actionSelectors";
import {
ACTION_INVALID_NAME_ERROR,
ACTION_NAME_CONFLICT_ERROR,
createMessage,
} from "ee/constants/messages";
import type { ReduxAction } from "ee/constants/ReduxActionConstants";
import { useEventCallback } from "usehooks-ts";
interface NameSaveActionParams {
name: string;
id: string;
}
interface UseNameEditorProps {
entityId: string;
entityName: string;
nameSaveAction: (
params: NameSaveActionParams,
) => ReduxAction<NameSaveActionParams>;
nameErrorMessage?: (name: string) => string;
}
/**
* Provides a unified way to validate and save entity names.
*/
export function useNameEditor(props: UseNameEditorProps) {
const dispatch = useDispatch();
const {
entityId,
entityName,
nameErrorMessage = ACTION_NAME_CONFLICT_ERROR,
nameSaveAction,
} = props;
const isNew =
new URLSearchParams(window.location.search).get("editName") === "true";
const usedEntityNames = useSelector(
(state: AppState) => getUsedActionNames(state, ""),
shallowEqual,
);
const validateName = useEventCallback((name: string): string | null => {
if (!name || name.trim().length === 0) {
return createMessage(ACTION_INVALID_NAME_ERROR);
} else if (name !== entityName && !isNameValid(name, usedEntityNames)) {
return createMessage(nameErrorMessage, name);
}
return null;
});
const handleNameSave = useEventCallback((name: string) => {
if (name !== entityName && validateName(name) === null) {
dispatch(nameSaveAction({ id: entityId, name }));
}
});
return {
isNew,
validateName,
handleNameSave,
normalizeName: removeSpecialChars,
};
}

View File

@ -12995,6 +12995,7 @@ __metadata:
typescript: ^5.5.4 typescript: ^5.5.4
unescape-js: ^1.1.4 unescape-js: ^1.1.4
url-search-params-polyfill: ^8.0.0 url-search-params-polyfill: ^8.0.0
usehooks-ts: ^3.1.0
uuid: ^9.0.0 uuid: ^9.0.0
validate-color: ^2.2.4 validate-color: ^2.2.4
web-vitals: 3.5.2 web-vitals: 3.5.2
@ -33239,6 +33240,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"usehooks-ts@npm:^3.1.0":
version: 3.1.0
resolution: "usehooks-ts@npm:3.1.0"
dependencies:
lodash.debounce: ^4.0.8
peerDependencies:
react: ^16.8.0 || ^17 || ^18
checksum: 4f850c0c5ab408afa52fa2ea2c93c488cd7065c82679eb1fb62cba12ca4c57ff62d52375acc6738823421fe6579ce3adcea1e2dc345ce4f549c593d2e51455b3
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2 version: 1.0.2
resolution: "util-deprecate@npm:1.0.2" resolution: "util-deprecate@npm:1.0.2"