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:
parent
a7bf302f9a
commit
299cc42aeb
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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> &
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
220
app/client/src/IDE/Components/FileTab/FileTab.test.tsx
Normal file
220
app/client/src/IDE/Components/FileTab/FileTab.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
188
app/client/src/IDE/Components/FileTab/FileTab.tsx
Normal file
188
app/client/src/IDE/Components/FileTab/FileTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
app/client/src/IDE/Components/FileTab/constants.ts
Normal file
5
app/client/src/IDE/Components/FileTab/constants.ts
Normal 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",
|
||||||
|
};
|
||||||
2
app/client/src/IDE/Components/FileTab/index.ts
Normal file
2
app/client/src/IDE/Components/FileTab/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { FileTab } from "./FileTab";
|
||||||
|
export type { FileTabProps } from "./FileTab";
|
||||||
68
app/client/src/IDE/Components/FileTab/styles.tsx
Normal file
68
app/client/src/IDE/Components/FileTab/styles.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
64
app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx
Normal file
64
app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -37,3 +37,10 @@ export const TabSelectors: Record<
|
||||||
itemUrlSelector: () => "",
|
itemUrlSelector: () => "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SCROLL_AREA_OPTIONS = {
|
||||||
|
overflow: {
|
||||||
|
x: "scroll",
|
||||||
|
y: "hidden",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
70
app/client/src/utils/hooks/useNameEditor.ts
Normal file
70
app/client/src/utils/hooks/useNameEditor.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user