diff --git a/app/client/jest.config.js b/app/client/jest.config.js index bc9bc713c12..a57de612132 100644 --- a/app/client/jest.config.js +++ b/app/client/jest.config.js @@ -17,7 +17,7 @@ module.exports = { moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node", "css"], moduleDirectories: ["node_modules", "src", "test"], transformIgnorePatterns: [ - "/node_modules/(?!codemirror|konva|react-dnd|dnd-core|@babel|(@blueprintjs)|@github|lodash-es|@draft-js-plugins|react-documents|linkedom|assert-never|axios)", + "/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)$": "/test/__mocks__/styleMock.js", diff --git a/app/client/package.json b/app/client/package.json index a8dbd768511..7b9f30de885 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -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", diff --git a/app/client/packages/design-system/ads/src/Text/Text.stories.tsx b/app/client/packages/design-system/ads/src/Text/Text.stories.tsx index 640ed86334e..3eff9aec033 100644 --- a/app/client/packages/design-system/ads/src/Text/Text.stories.tsx +++ b/app/client/packages/design-system/ads/src/Text/Text.stories.tsx @@ -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) => { + setText(e.target.value); + }, + }), + [], + ); return ( - { - // @ts-expect-error type error - setText(e.target.value); - }} - > + {text} ); diff --git a/app/client/packages/design-system/ads/src/Text/Text.styles.tsx b/app/client/packages/design-system/ads/src/Text/Text.styles.tsx index ec4a7b1f8b9..55f278fa7b1 100644 --- a/app/client/packages/design-system/ads/src/Text/Text.styles.tsx +++ b/app/client/packages/design-system/ads/src/Text/Text.styles.tsx @@ -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); } `; diff --git a/app/client/packages/design-system/ads/src/Text/Text.tsx b/app/client/packages/design-system/ads/src/Text/Text.tsx index 493555010f2..7413380359b 100644 --- a/app/client/packages/design-system/ads/src/Text/Text.tsx +++ b/app/client/packages/design-system/ads/src/Text/Text.tsx @@ -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({ - children, - className, - color, - inputProps, - isEditable, - kind, - onChange, - renderAs, - ...rest -}: TextProps) { +const Text = forwardRef(function Text( + { + children, + className, + color, + inputProps, + inputRef, + isEditable, + kind, + renderAs, + ...rest + }: TextProps, + ref, +) { return ( {isEditable && typeof children === "string" ? ( - + ) : ( children )} ); -} +}); Text.displayName = "Text"; Text.defaultProps = { renderAs: "span", - kind: "span", + kind: "body-m", }; export { Text }; diff --git a/app/client/packages/design-system/ads/src/Text/Text.types.ts b/app/client/packages/design-system/ads/src/Text/Text.types.ts index ec105aeaf1d..76323ede3fe 100644 --- a/app/client/packages/design-system/ads/src/Text/Text.types.ts +++ b/app/client/packages/design-system/ads/src/Text/Text.types.ts @@ -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) => void; /** input component props while isEditable is true */ - inputProps?: Omit< - React.InputHTMLAttributes, - "value" | "onChange" - >; + inputProps?: Omit, "value">; + /** ref for input component */ + inputRef?: React.RefObject; } & React.HTMLAttributes & React.HTMLAttributes & React.HTMLAttributes & diff --git a/app/client/src/IDE/Components/FileTab.tsx b/app/client/src/IDE/Components/FileTab.tsx deleted file mode 100644 index 9d8912d44b8..00000000000 --- a/app/client/src/IDE/Components/FileTab.tsx +++ /dev/null @@ -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 ( - - {icon ? {icon} : null} - {title} - {/* not using button component because of the size not matching design */} - - - ); -}; diff --git a/app/client/src/IDE/Components/FileTab/FileTab.test.tsx b/app/client/src/IDE/Components/FileTab/FileTab.test.tsx new file mode 100644 index 00000000000..38ff125fe0f --- /dev/null +++ b/app/client/src/IDE/Components/FileTab/FileTab.test.tsx @@ -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 = () => ; + 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( + } + 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(); + }); +}); diff --git a/app/client/src/IDE/Components/FileTab/FileTab.tsx b/app/client/src/IDE/Components/FileTab/FileTab.tsx new file mode 100644 index 00000000000..5de08640815 --- /dev/null +++ b/app/client/src/IDE/Components/FileTab/FileTab.tsx @@ -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(null); + const inputRef = useRef(null); + + const handleKeyUp = useEventCallback( + (e: React.KeyboardEvent) => { + 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) => { + 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 ( + + {icon && !isLoading ? ( + {icon} + ) : null} + {isLoading && } + + + + {currentTitle} + + + + + + + + ); +}; diff --git a/app/client/src/IDE/Components/FileTab/constants.ts b/app/client/src/IDE/Components/FileTab/constants.ts new file mode 100644 index 00000000000..4408a5b065a --- /dev/null +++ b/app/client/src/IDE/Components/FileTab/constants.ts @@ -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", +}; diff --git a/app/client/src/IDE/Components/FileTab/index.ts b/app/client/src/IDE/Components/FileTab/index.ts new file mode 100644 index 00000000000..3a8ba9834ec --- /dev/null +++ b/app/client/src/IDE/Components/FileTab/index.ts @@ -0,0 +1,2 @@ +export { FileTab } from "./FileTab"; +export type { FileTabProps } from "./FileTab"; diff --git a/app/client/src/IDE/Components/FileTab/styles.tsx b/app/client/src/IDE/Components/FileTab/styles.tsx new file mode 100644 index 00000000000..7fd681a6ce7 --- /dev/null +++ b/app/client/src/IDE/Components/FileTab/styles.tsx @@ -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); + } +`; diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 369c084d487..18ed368d503 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -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) => diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx new file mode 100644 index 00000000000..cc88734770b --- /dev/null +++ b/app/client/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx @@ -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 { + 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 ( + + ); +} diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.test.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.test.tsx deleted file mode 100644 index f6c46822e6b..00000000000 --- a/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.test.tsx +++ /dev/null @@ -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( - , - ); - - // 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( - , - ); - 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( - , - ); - 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); - }); -}); diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.tsx deleted file mode 100644 index 3d46bfba35d..00000000000 --- a/app/client/src/pages/Editor/IDE/EditorTabs/FileTabs.tsx +++ /dev/null @@ -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) => ( - navigateToTab(tab)} - onClose={(e) => onCloseClick(e, tab.key)} - title={tab.title} - /> - ))} - - ); -}; - -export default FileTabs; diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/constants.ts b/app/client/src/pages/Editor/IDE/EditorTabs/constants.ts index 69720db0251..9b06326a3fe 100644 --- a/app/client/src/pages/Editor/IDE/EditorTabs/constants.ts +++ b/app/client/src/pages/Editor/IDE/EditorTabs/constants.ts @@ -37,3 +37,10 @@ export const TabSelectors: Record< itemUrlSelector: () => "", }, }; + +export const SCROLL_AREA_OPTIONS = { + overflow: { + x: "scroll", + y: "hidden", + }, +} as const; diff --git a/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx b/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx index 6956979c0a1..09a923da070 100644 --- a/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx +++ b/app/client/src/pages/Editor/IDE/EditorTabs/index.tsx @@ -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 = () => { { gap="spaces-2" height="100%" > - + {files.map((tab) => ( + + ))} - {files.length > 0 ? : null} {/* Switch screen mode button */} diff --git a/app/client/src/pages/Editor/IDE/hooks.ts b/app/client/src/pages/Editor/IDE/hooks.ts index 128cb08caa0..e4eeba31981 100644 --- a/app/client/src/pages/Editor/IDE/hooks.ts +++ b/app/client/src/pages/Editor/IDE/hooks.ts @@ -180,11 +180,13 @@ export const useIDETabClickHandlers = () => { (item: EntityItem) => { const navigateToUrl = tabsConfig.itemUrlSelector(item, basePageId); - history.push(navigateToUrl, { - invokedBy: NavigationMethod.EditorTabs, - }); + if (navigateToUrl !== history.location.pathname) { + history.push(navigateToUrl, { + invokedBy: NavigationMethod.EditorTabs, + }); + } }, - [segment, basePageId], + [tabsConfig, basePageId], ); const closeClickHandler = useCallback( diff --git a/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx b/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx index ff1a598bc1b..0ce1112c3c1 100644 --- a/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx +++ b/app/client/src/pages/Editor/JSEditor/JSObjectNameEditor.tsx @@ -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 ( { 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. */ diff --git a/app/client/src/utils/hooks/useNameEditor.ts b/app/client/src/utils/hooks/useNameEditor.ts new file mode 100644 index 00000000000..4dbc0fe6e7f --- /dev/null +++ b/app/client/src/utils/hooks/useNameEditor.ts @@ -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; + 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, + }; +} diff --git a/app/client/yarn.lock b/app/client/yarn.lock index c97f664a5bc..92540677b64 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -12994,6 +12994,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 @@ -33017,6 +33018,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"