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"],
|
||||
moduleDirectories: ["node_modules", "src", "test"],
|
||||
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: {
|
||||
"\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@
|
|||
"typescript": "^5.5.4",
|
||||
"unescape-js": "^1.1.4",
|
||||
"url-search-params-polyfill": "^8.0.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"uuid": "^9.0.0",
|
||||
"validate-color": "^2.2.4",
|
||||
"web-vitals": "3.5.2",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Text } from "./Text";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
|
|
@ -25,15 +25,17 @@ export const EditableTextStory: Story = {
|
|||
},
|
||||
render: function Render(args) {
|
||||
const [text, setText] = React.useState(args.children);
|
||||
const inputProps = useMemo(
|
||||
() => ({
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...args}
|
||||
onChange={(e) => {
|
||||
// @ts-expect-error type error
|
||||
setText(e.target.value);
|
||||
}}
|
||||
>
|
||||
<Text inputProps={inputProps} {...args}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ export const StyledText = styled.span<{
|
|||
isBold?: boolean;
|
||||
isItalic?: boolean;
|
||||
isUnderlined?: boolean;
|
||||
isStriked?: boolean;
|
||||
isStrikethrough?: boolean;
|
||||
isEditable?: boolean;
|
||||
}>`
|
||||
${TypographyScales}
|
||||
|
|
@ -160,8 +160,8 @@ export const StyledText = styled.span<{
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Striked style */
|
||||
&[data-striked="true"] {
|
||||
/* Strikethrough style */
|
||||
&[data-strikethrough="true"] {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
|
|
@ -191,10 +191,8 @@ export const StyledEditableInput = styled.input`
|
|||
border: 1px solid transparent;
|
||||
border-radius: var(--ads-v2-border-radius);
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: -3px;
|
||||
width: 100%;
|
||||
padding: var(--ads-v2-spaces-1);
|
||||
|
|
@ -205,6 +203,6 @@ export const StyledEditableInput = styled.input`
|
|||
|
||||
&:focus,
|
||||
&: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 type { TextProps } from "./Text.types";
|
||||
import type { RenderAsElement, TextProps } from "./Text.types";
|
||||
import { StyledEditableInput, StyledText } from "./Text.styles";
|
||||
import { TextClassName } from "./Text.constants";
|
||||
|
||||
|
|
@ -9,17 +9,20 @@ TODO:
|
|||
- add segment header style to list of styles
|
||||
*/
|
||||
|
||||
function Text({
|
||||
const Text = forwardRef<RenderAsElement, TextProps>(function Text(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
color,
|
||||
inputProps,
|
||||
inputRef,
|
||||
isEditable,
|
||||
kind,
|
||||
onChange,
|
||||
renderAs,
|
||||
...rest
|
||||
}: TextProps) {
|
||||
}: TextProps,
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<StyledText
|
||||
as={renderAs}
|
||||
|
|
@ -27,31 +30,28 @@ function Text({
|
|||
color={color}
|
||||
data-bold={rest.isBold}
|
||||
data-italic={rest.isItalic}
|
||||
data-striked={rest.isStriked}
|
||||
data-strikethrough={rest.isStrikethrough}
|
||||
data-underlined={rest.isUnderlined}
|
||||
data-value={isEditable && typeof children === "string" ? children : null}
|
||||
isEditable={isEditable && typeof children === "string"}
|
||||
kind={kind}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{isEditable && typeof children === "string" ? (
|
||||
<StyledEditableInput
|
||||
onChange={onChange}
|
||||
value={children}
|
||||
{...inputProps}
|
||||
/>
|
||||
<StyledEditableInput ref={inputRef} value={children} {...inputProps} />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</StyledText>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Text.displayName = "Text";
|
||||
|
||||
Text.defaultProps = {
|
||||
renderAs: "span",
|
||||
kind: "span",
|
||||
kind: "body-m",
|
||||
};
|
||||
|
||||
export { Text };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type React from "react";
|
||||
|
||||
/** Text style variant */
|
||||
export type TextKind =
|
||||
| "heading-xl"
|
||||
| "heading-l"
|
||||
|
|
@ -14,7 +15,14 @@ export type TextKind =
|
|||
| "action-s"
|
||||
| "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 = {
|
||||
/** to change the rendering component */
|
||||
renderAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "label";
|
||||
|
|
@ -32,17 +40,14 @@ export type TextProps = {
|
|||
isItalic?: boolean;
|
||||
/** whether the text is underlined or not */
|
||||
isUnderlined?: boolean;
|
||||
/** whether the text is striked or not */
|
||||
isStriked?: boolean;
|
||||
/** whether the text is strikethrough or not */
|
||||
isStrikethrough?: boolean;
|
||||
/** whether the text is editable or not */
|
||||
isEditable?: boolean;
|
||||
/** onChange event for editable text */
|
||||
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
/** input component props while isEditable is true */
|
||||
inputProps?: Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange"
|
||||
>;
|
||||
inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value">;
|
||||
/** ref for input component */
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
} & React.HTMLAttributes<HTMLLabelElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
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 =
|
||||
"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.";
|
||||
|
||||
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: () => "",
|
||||
},
|
||||
};
|
||||
|
||||
export const SCROLL_AREA_OPTIONS = {
|
||||
overflow: {
|
||||
x: "scroll",
|
||||
y: "hidden",
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import {
|
|||
EditorEntityTabState,
|
||||
EditorViewMode,
|
||||
} from "ee/entities/IDE/constants";
|
||||
import FileTabs from "./FileTabs";
|
||||
|
||||
import Container from "./Container";
|
||||
import { useCurrentEditorState, useIDETabClickHandlers } from "../hooks";
|
||||
import { TabSelectors } from "./constants";
|
||||
import { SCROLL_AREA_OPTIONS, TabSelectors } from "./constants";
|
||||
import { AddButton } from "./AddButton";
|
||||
import { Announcement } from "../EditorPane/components/Announcement";
|
||||
import { useLocation } from "react-router";
|
||||
|
|
@ -25,6 +25,10 @@ import { ScreenModeToggle } from "./ScreenModeToggle";
|
|||
import { AddTab } from "./AddTab";
|
||||
import { setListViewActiveState } from "actions/ideActions";
|
||||
|
||||
import { useEventCallback } from "usehooks-ts";
|
||||
|
||||
import { EditableTab } from "./EditableTab";
|
||||
|
||||
const EditorTabs = () => {
|
||||
const isSideBySideEnabled = useSelector(getIsSideBySideEnabled);
|
||||
const ideViewMode = useSelector(getIDEViewMode);
|
||||
|
|
@ -41,21 +45,21 @@ const EditorTabs = () => {
|
|||
// Turn off list view while changing segment, files
|
||||
useEffect(() => {
|
||||
dispatch(setListViewActiveState(false));
|
||||
}, [currentEntity.id, currentEntity.entity, files, segmentMode]);
|
||||
}, [currentEntity.id, currentEntity.entity, files, segmentMode, dispatch]);
|
||||
|
||||
// Show list view if all tabs is closed
|
||||
useEffect(() => {
|
||||
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) {
|
||||
dispatch(setListViewActiveState(true));
|
||||
}
|
||||
}, [files, segmentMode, currentEntity.entity]);
|
||||
}, [files, segmentMode, currentEntity.entity, dispatch]);
|
||||
|
||||
// scroll to the active tab
|
||||
useEffect(() => {
|
||||
const activetab = document.querySelector(".editor-tab.active");
|
||||
const activeTab = document.querySelector(".editor-tab.active");
|
||||
|
||||
if (activetab) {
|
||||
activetab.scrollIntoView({
|
||||
if (activeTab) {
|
||||
activeTab.scrollIntoView({
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
|
|
@ -74,24 +78,25 @@ const EditorTabs = () => {
|
|||
}
|
||||
}, [files]);
|
||||
|
||||
if (!isSideBySideEnabled) return null;
|
||||
|
||||
if (segment === EditorEntityTab.UI) return null;
|
||||
|
||||
const handleHamburgerClick = () => {
|
||||
const handleHamburgerClick = useEventCallback(() => {
|
||||
if (files.length === 0 && segmentMode !== EditorEntityTabState.Add) return;
|
||||
|
||||
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));
|
||||
tabClickHandler(tab);
|
||||
};
|
||||
});
|
||||
|
||||
const newTabClickHandler = () => {
|
||||
const handleNewTabClick = useEventCallback(() => {
|
||||
dispatch(setListViewActiveState(false));
|
||||
};
|
||||
});
|
||||
|
||||
if (!isSideBySideEnabled) return null;
|
||||
|
||||
if (segment === EditorEntityTab.UI) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -108,13 +113,8 @@ const EditorTabs = () => {
|
|||
<ScrollArea
|
||||
className="h-[32px] top-[0.5px]"
|
||||
data-testid="t--editor-tabs"
|
||||
options={{
|
||||
overflow: {
|
||||
x: "scroll",
|
||||
y: "hidden",
|
||||
},
|
||||
}}
|
||||
size={"sm"}
|
||||
options={SCROLL_AREA_OPTIONS}
|
||||
size="sm"
|
||||
>
|
||||
<Flex
|
||||
className="items-center"
|
||||
|
|
@ -122,21 +122,28 @@ const EditorTabs = () => {
|
|||
gap="spaces-2"
|
||||
height="100%"
|
||||
>
|
||||
<FileTabs
|
||||
currentEntity={currentEntity}
|
||||
isListActive={isListViewActive}
|
||||
navigateToTab={onTabClick}
|
||||
{files.map((tab) => (
|
||||
<EditableTab
|
||||
icon={tab.icon}
|
||||
id={tab.key}
|
||||
isActive={
|
||||
currentEntity.id === tab.key &&
|
||||
segmentMode !== EditorEntityTabState.Add &&
|
||||
!isListViewActive
|
||||
}
|
||||
key={tab.key}
|
||||
onClick={handleTabClick(tab)}
|
||||
onClose={closeClickHandler}
|
||||
tabs={files}
|
||||
title={tab.title}
|
||||
/>
|
||||
))}
|
||||
<AddTab
|
||||
isListActive={isListViewActive}
|
||||
newTabClickCallback={newTabClickHandler}
|
||||
newTabClickCallback={handleNewTabClick}
|
||||
onClose={closeClickHandler}
|
||||
/>
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
{files.length > 0 ? <AddButton /> : null}
|
||||
{/* Switch screen mode button */}
|
||||
<ScreenModeToggle />
|
||||
|
|
|
|||
|
|
@ -180,11 +180,13 @@ export const useIDETabClickHandlers = () => {
|
|||
(item: EntityItem) => {
|
||||
const navigateToUrl = tabsConfig.itemUrlSelector(item, basePageId);
|
||||
|
||||
if (navigateToUrl !== history.location.pathname) {
|
||||
history.push(navigateToUrl, {
|
||||
invokedBy: NavigationMethod.EditorTabs,
|
||||
});
|
||||
}
|
||||
},
|
||||
[segment, basePageId],
|
||||
[tabsConfig, basePageId],
|
||||
);
|
||||
|
||||
const closeClickHandler = useCallback(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "ee/selectors/entitiesSelector";
|
||||
import {
|
||||
ACTION_NAME_PLACEHOLDER,
|
||||
JSOBJECT_ID_NOT_FOUND_IN_URL,
|
||||
JS_OBJECT_ID_NOT_FOUND_IN_URL,
|
||||
createMessage,
|
||||
} from "ee/constants/messages";
|
||||
import EditableText, {
|
||||
|
|
@ -62,7 +62,7 @@ export function JSObjectNameEditor(props: JSObjectNameEditorProps) {
|
|||
return (
|
||||
<NameEditorComponent
|
||||
id={currentJSObjectConfig?.id}
|
||||
idUndefinedErrorMessage={JSOBJECT_ID_NOT_FOUND_IN_URL}
|
||||
idUndefinedErrorMessage={JS_OBJECT_ID_NOT_FOUND_IN_URL}
|
||||
name={currentJSObjectConfig?.name}
|
||||
onSaveName={props.saveJSObjectName}
|
||||
saveStatus={saveStatus}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export const getDefaultSelectedWidgetIds = (state: AppState) => {
|
|||
export const getIsSavingForApiName = (state: AppState, id: string) =>
|
||||
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.
|
||||
*/
|
||||
|
|
@ -34,6 +38,10 @@ export const getErrorForApiName = (state: AppState, id: string) =>
|
|||
export const getIsSavingForJSObjectName = (state: AppState, id: string) =>
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
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
|
||||
unescape-js: ^1.1.4
|
||||
url-search-params-polyfill: ^8.0.0
|
||||
usehooks-ts: ^3.1.0
|
||||
uuid: ^9.0.0
|
||||
validate-color: ^2.2.4
|
||||
web-vitals: 3.5.2
|
||||
|
|
@ -33239,6 +33240,17 @@ __metadata:
|
|||
languageName: node
|
||||
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":
|
||||
version: 1.0.2
|
||||
resolution: "util-deprecate@npm:1.0.2"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user