diff --git a/src/IDE/Components/EditableName/EditableName.test.tsx b/src/IDE/Components/EditableName/EditableName.test.tsx new file mode 100644 index 000000000000..77a4f756555b --- /dev/null +++ b/src/IDE/Components/EditableName/EditableName.test.tsx @@ -0,0 +1,212 @@ +import React from "react"; +import { EditableName } from "./EditableName"; +import { render } from "test/testUtils"; +import "@testing-library/jest-dom"; +import { Icon } from "@appsmith/ads"; +import { fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +describe("EditableName", () => { + const mockOnNameSave = jest.fn(); + const mockOnExitEditing = jest.fn(); + + const name = "test_name"; + const TabIcon = () => ; + const KEY_CONFIG = { + ENTER: { key: "Enter", keyCode: 13 }, + ESC: { key: "Esc", keyCode: 27 }, + }; + + const setup = ({ isEditing = false, isLoading = false }) => { + // Define the props + const props = { + name, + icon: , + isEditing, + onNameSave: mockOnNameSave, + exitEditing: mockOnExitEditing, + isLoading, + }; + + // Render the component + const utils = render(); + + return { + ...props, + ...utils, + }; + }; + + test("renders component", () => { + const utils = setup({}); + const editableNameElement = utils.getByText(utils.name); + + expect(editableNameElement).toBeInTheDocument(); + expect(editableNameElement.textContent).toBe(name); + }); + + test("renders input when editing", () => { + const utils = setup({ isEditing: true }); + + const editableNameElement = utils.queryByText(utils.name); + + expect(editableNameElement).not.toBeInTheDocument(); + + const inputElement = utils.getByRole("textbox"); + + expect(inputElement).toBeInTheDocument(); + }); + + describe("valid input actions", () => { + test("submit event", async () => { + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + + // hit enter + const enterTitle = "enter_title"; + + fireEvent.change(getByRole("textbox"), { + target: { value: enterTitle }, + }); + expect(getByRole("textbox")).toHaveValue(enterTitle); + + fireEvent.keyUp(getByRole("textbox"), KEY_CONFIG.ENTER); + + expect(onNameSave).toHaveBeenCalledWith(enterTitle); + expect(exitEditing).toHaveBeenCalled(); + }); + + test("outside click event", async () => { + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + + const clickOutsideTitle = "click_outside_title"; + + fireEvent.change(getByRole("textbox"), { + target: { value: clickOutsideTitle }, + }); + + await userEvent.click(document.body); + + expect(onNameSave).toHaveBeenCalledWith(clickOutsideTitle); + expect(exitEditing).toHaveBeenCalled(); + }); + + test("esc key event", async () => { + const escapeTitle = "escape_title"; + + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + + fireEvent.change(getByRole("textbox"), { + target: { value: escapeTitle }, + }); + + fireEvent.keyUp(getByRole("textbox"), KEY_CONFIG.ESC); + + expect(exitEditing).toHaveBeenCalled(); + expect(onNameSave).not.toHaveBeenCalledWith(escapeTitle); + }); + + test("focus out event", async () => { + const focusOutTitle = "focus_out_title"; + + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + + const inputElement = getByRole("textbox"); + + fireEvent.change(inputElement, { + target: { value: focusOutTitle }, + }); + + fireEvent.keyUp(inputElement, KEY_CONFIG.ESC); + expect(exitEditing).toHaveBeenCalled(); + expect(onNameSave).not.toHaveBeenCalledWith(focusOutTitle); + }); + }); + + describe("invalid input actions", () => { + const invalidTitle = "else"; + const validationError = + "else is already being used or is a restricted keyword."; + + test("click outside", async () => { + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + const inputElement = getByRole("textbox"); + + fireEvent.change(inputElement, { + target: { value: invalidTitle }, + }); + + fireEvent.keyUp(inputElement, KEY_CONFIG.ENTER); + + expect(getByRole("tooltip")).toBeInTheDocument(); + + expect(getByRole("tooltip").textContent).toEqual(validationError); + + await userEvent.click(document.body); + + expect(getByRole("tooltip").textContent).toEqual(""); + + expect(exitEditing).toHaveBeenCalled(); + expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle); + }); + + test("esc key", async () => { + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + const inputElement = getByRole("textbox"); + + fireEvent.change(inputElement, { + target: { value: invalidTitle }, + }); + + fireEvent.keyUp(inputElement, KEY_CONFIG.ENTER); + fireEvent.keyUp(inputElement, KEY_CONFIG.ESC); + + expect(getByRole("tooltip")).toBeInTheDocument(); + + expect(getByRole("tooltip").textContent).toEqual(""); + expect(exitEditing).toHaveBeenCalled(); + expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle); + }); + + test("focus out event", async () => { + const { exitEditing, getByRole, onNameSave } = setup({ + isEditing: true, + }); + const inputElement = getByRole("textbox"); + + fireEvent.change(inputElement, { + target: { value: invalidTitle }, + }); + + fireEvent.keyUp(inputElement, KEY_CONFIG.ENTER); + fireEvent.focusOut(inputElement); + expect(getByRole("tooltip").textContent).toEqual(""); + expect(exitEditing).toHaveBeenCalled(); + expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle); + }); + + test("prevents saving empty name", () => { + const { getByRole, onNameSave } = setup({ isEditing: true }); + const input = getByRole("textbox"); + + fireEvent.change(input, { target: { value: "" } }); + fireEvent.keyUp(input, KEY_CONFIG.ENTER); + + expect(onNameSave).not.toHaveBeenCalledWith(""); + expect(getByRole("tooltip")).toHaveTextContent( + "Please enter a valid name", + ); + }); + }); +}); diff --git a/src/IDE/Components/EditableName/EditableName.tsx b/src/IDE/Components/EditableName/EditableName.tsx new file mode 100644 index 000000000000..074c7ca6a829 --- /dev/null +++ b/src/IDE/Components/EditableName/EditableName.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Spinner, Text, Tooltip } from "@appsmith/ads"; +import { useEventCallback, useEventListener } from "usehooks-ts"; +import { usePrevious } from "@mantine/hooks"; +import { useNameEditor } from "./useNameEditor"; + +interface EditableTextProps { + name: string; + isLoading?: boolean; + onNameSave: (name: string) => void; + isEditing: boolean; + exitEditing: () => void; + icon: React.ReactNode; + inputTestId?: string; +} + +export const EditableName = ({ + exitEditing, + icon, + inputTestId, + isEditing, + isLoading = false, + name, + onNameSave, +}: EditableTextProps) => { + const previousName = usePrevious(name); + const [editableName, setEditableName] = useState(name); + const [validationError, setValidationError] = useState(null); + const inputRef = useRef(null); + + const { normalizeName, validateName } = useNameEditor({ + entityName: name, + }); + + const handleKeyUp = useEventCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const nameError = validateName(editableName); + + if (nameError === null) { + exitEditing(); + onNameSave(editableName); + } else { + setValidationError(nameError); + } + } else if (e.key === "Escape") { + exitEditing(); + setEditableName(name); + setValidationError(null); + } else { + setValidationError(null); + } + }, + ); + + const handleTitleChange = useEventCallback( + (e: React.ChangeEvent) => { + setEditableName(normalizeName(e.target.value)); + }, + ); + + const inputProps = useMemo( + () => ({ + ["data-testid"]: inputTestId, + onKeyUp: handleKeyUp, + onChange: handleTitleChange, + autoFocus: true, + style: { paddingTop: 0, paddingBottom: 0, left: -1, top: -1 }, + }), + [handleKeyUp, handleTitleChange], + ); + + useEventListener( + "focusout", + function handleFocusOut() { + if (isEditing) { + const nameError = validateName(editableName); + + exitEditing(); + + if (nameError === null) { + onNameSave(editableName); + } else { + setEditableName(name); + setValidationError(null); + } + } + }, + inputRef, + ); + + useEffect( + function syncEditableTitle() { + if (!isEditing && previousName !== name) { + setEditableName(name); + } + }, + [name, previousName, 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 ( + <> + {isLoading ? : icon} + + + {editableName} + + + + ); +}; diff --git a/src/IDE/Components/EditableName/index.ts b/src/IDE/Components/EditableName/index.ts new file mode 100644 index 000000000000..875359b04b0d --- /dev/null +++ b/src/IDE/Components/EditableName/index.ts @@ -0,0 +1 @@ +export { EditableName } from "./EditableName"; diff --git a/src/utils/hooks/useNameEditor.ts b/src/IDE/Components/EditableName/useNameEditor.ts similarity index 57% rename from src/utils/hooks/useNameEditor.ts rename to src/IDE/Components/EditableName/useNameEditor.ts index 4dbc0fe6e7f9..40e7bf1d18d0 100644 --- a/src/utils/hooks/useNameEditor.ts +++ b/src/IDE/Components/EditableName/useNameEditor.ts @@ -1,27 +1,16 @@ -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 { shallowEqual, useSelector } from "react-redux"; +import type { AppState } from "ee/reducers"; +import { getUsedActionNames } from "selectors/actionSelectors"; import { useEventCallback } from "usehooks-ts"; - -interface NameSaveActionParams { - name: string; - id: string; -} +import { isNameValid, removeSpecialChars } from "utils/helpers"; interface UseNameEditorProps { - entityId: string; entityName: string; - nameSaveAction: ( - params: NameSaveActionParams, - ) => ReduxAction; nameErrorMessage?: (name: string) => string; } @@ -29,16 +18,7 @@ interface UseNameEditorProps { * 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 { entityName, nameErrorMessage = ACTION_NAME_CONFLICT_ERROR } = props; const usedEntityNames = useSelector( (state: AppState) => getUsedActionNames(state, ""), @@ -55,16 +35,8 @@ export function useNameEditor(props: UseNameEditorProps) { 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/src/IDE/Components/FileTab/FileTab.test.tsx b/src/IDE/Components/FileTab/FileTab.test.tsx index dc1cd307c776..6650b2161add 100644 --- a/src/IDE/Components/FileTab/FileTab.test.tsx +++ b/src/IDE/Components/FileTab/FileTab.test.tsx @@ -2,8 +2,7 @@ /* 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 { render, fireEvent } from "@testing-library/react"; import { Icon } from "@appsmith/ads"; import { FileTab } from "./FileTab"; @@ -12,44 +11,29 @@ import { DATA_TEST_ID } from "./constants"; describe("FileTab", () => { const mockOnClick = jest.fn(); const mockOnClose = jest.fn(); + const mockOnDoubleClick = 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 setup = () => { const utils = render( } isActive - isChangePermitted - isLoading={isLoading} onClick={mockOnClick} onClose={mockOnClose} + onDoubleClick={mockOnDoubleClick} title={TITLE} - />, + > + + {TITLE} + , ); const tabElement = utils.getByText(TITLE); return { tabElement, ...utils, - ...mockEditorConfig, }; }; @@ -65,157 +49,16 @@ describe("FileTab", () => { 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", () => { + test("double click event is fired", () => { const { getByTestId, tabElement } = setup(); fireEvent.doubleClick(tabElement); - within(tabElement).getByTestId(DATA_TEST_ID.INPUT); + + expect(mockOnDoubleClick).toHaveBeenCalled(); 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/src/IDE/Components/FileTab/FileTab.tsx b/src/IDE/Components/FileTab/FileTab.tsx index 8198e27050d0..193b5323b5bd 100644 --- a/src/IDE/Components/FileTab/FileTab.tsx +++ b/src/IDE/Components/FileTab/FileTab.tsx @@ -1,183 +1,38 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React from "react"; import clsx from "classnames"; -import { noop } from "lodash"; -import { Icon, Spinner, Tooltip } from "@appsmith/ads"; +import { Icon } 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; - isChangePermitted?: 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; - }; + children: React.ReactNode; + onDoubleClick?: () => void; } export const FileTab = ({ - editorConfig, - icon, + children, isActive, - isChangePermitted = false, - isLoading = false, onClick, onClose, + onDoubleClick, 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 && isChangePermitted ? 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} - - - + {children} { +const PluginActionNameEditor = ({ + saveActionName, +}: PluginActionNameEditorProps) => { const { action, plugin } = usePluginActionContext(); - const title = action.name; - const previousTitle = usePrevious(title); - const [editableTitle, setEditableTitle] = useState(title); - const [validationError, setValidationError] = useState(null); - const inputRef = useRef(null); const isLoading = useSelector( (state) => getSavingStatusForActionName(state, action?.id || "").isSaving, ); - const { handleNameSave, normalizeName, validateName } = useNameEditor({ - entityId: action.id, - entityName: title, - nameSaveAction: props.saveActionName, - }); - const { setFalse: exitEditMode, setTrue: enterEditMode, @@ -85,117 +75,28 @@ const PluginActionNameEditor = (props: PluginActionNameEditorProps) => { isFeatureEnabled, action?.userPermissions, ); - - const currentTitle = - isEditing || isLoading || title !== editableTitle ? editableTitle : title; const iconUrl = getAssetUrl(plugin?.iconLocation) || ""; const icon = ActionUrlIcon(iconUrl); - const handleKeyUp = useEventCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - const nameError = validateName(editableTitle); - - if (nameError === null) { - exitEditMode(); - handleNameSave(editableTitle); - } else { - setValidationError(nameError); - } - } else if (e.key === "Escape") { - exitEditMode(); - setEditableTitle(title); - setValidationError(null); - } else { - setValidationError(null); - } - }, - ); - - const handleTitleChange = useEventCallback( - (e: React.ChangeEvent) => { - setEditableTitle(normalizeName(e.target.value)); - }, - ); - - const handleEnterEditMode = useEventCallback(() => { - setEditableTitle(title); - enterEditMode(); - }); - - const handleDoubleClick = isChangePermitted ? handleEnterEditMode : noop; - - const inputProps = useMemo( - () => ({ - onKeyUp: handleKeyUp, - onChange: handleTitleChange, - autoFocus: true, - style: { - paddingTop: 0, - paddingBottom: 0, - left: -1, - top: -1, - }, - }), - [handleKeyUp, handleTitleChange], - ); - - useEventListener( - "focusout", - function handleFocusOut() { - if (isEditing) { - const nameError = validateName(editableTitle); - - exitEditMode(); - - if (nameError === null) { - handleNameSave(editableTitle); - } else { - setEditableTitle(title); - setValidationError(null); - } - } - }, - inputRef, - ); + const handleDoubleClick = isChangePermitted ? enterEditMode : noop; - useEffect( - function syncEditableTitle() { - if (!isEditing && previousTitle !== title) { - setEditableTitle(title); - } + const handleNameSave = useCallback( + (name: string) => { + saveActionName({ id: action.id, name }); }, - [title, previousTitle, isEditing], - ); - - useEffect( - function recaptureFocusInEventOfFocusRetention() { - const input = inputRef.current; - - if (isEditing && input) { - setTimeout(() => { - input.focus(); - }, 200); - } - }, - [isEditing], + [action.id, saveActionName], ); return ( - {icon && !isLoading ? {icon} : null} - {isLoading && } - - - - {currentTitle} - - + {icon}} + isEditing={isEditing} + isLoading={isLoading} + name={action.name} + onNameSave={handleNameSave} + /> ); }; diff --git a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx index 7ade07c38535..2847f3481bf9 100644 --- a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx +++ b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Copy.tsx @@ -7,7 +7,11 @@ import { MenuSub, MenuSubContent, MenuSubTrigger } from "@appsmith/ads"; import { CONTEXT_COPY, createMessage } from "ee/constants/messages"; import { PageMenuItem } from "./PageMenuItem"; -export const Copy = () => { +interface Props { + disabled?: boolean; +} + +export const Copy = ({ disabled }: Props) => { const menuPages = useSelector(getPageList); const { action } = usePluginActionContext(); const dispatch = useDispatch(); @@ -26,7 +30,7 @@ export const Copy = () => { return ( - + {createMessage(CONTEXT_COPY)} diff --git a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Delete.tsx b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Delete.tsx index 498726f28163..41c0243856b2 100644 --- a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Delete.tsx +++ b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Delete.tsx @@ -7,7 +7,11 @@ import { } from "ee/constants/messages"; import { MenuItem } from "@appsmith/ads"; -export const Delete = () => { +interface Props { + disabled?: boolean; +} + +export const Delete = ({ disabled }: Props) => { const { handleDeleteClick } = useHandleDeleteClick(); const [confirmDelete, setConfirmDelete] = useState(false); @@ -26,6 +30,7 @@ export const Delete = () => { return ( diff --git a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Move.tsx b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Move.tsx index 88fe248884f1..a73e52e8bb05 100644 --- a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Move.tsx +++ b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Move.tsx @@ -13,7 +13,11 @@ import { import { CONTEXT_MOVE, createMessage } from "ee/constants/messages"; import { PageMenuItem } from "./PageMenuItem"; -export const Move = () => { +interface Props { + disabled?: boolean; +} + +export const Move = ({ disabled }: Props) => { const dispatch = useDispatch(); const { action } = usePluginActionContext(); @@ -38,7 +42,7 @@ export const Move = () => { return ( - + {createMessage(CONTEXT_MOVE)} diff --git a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Rename.tsx b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Rename.tsx new file mode 100644 index 000000000000..471b6041b26e --- /dev/null +++ b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/Rename.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { MenuItem } from "@appsmith/ads"; + +interface Props { + disabled?: boolean; +} + +export const Rename = ({ disabled }: Props) => { + return ( + + Rename + + ); +}; diff --git a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/ToolbarMenu.tsx b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/ToolbarMenu.tsx index 37ffe8876711..e321611af6a5 100644 --- a/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/ToolbarMenu.tsx +++ b/src/ce/pages/Editor/AppPluginActionEditor/components/ToolbarMenu/ToolbarMenu.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { MenuSeparator } from "@appsmith/ads"; import { getHasDeleteActionPermission, getHasManageActionPermission, @@ -13,7 +14,7 @@ import { ConvertToModuleCTA } from "../ConvertToModule"; import { Move } from "./Move"; import { Copy } from "./Copy"; import { Delete } from "./Delete"; -import { MenuSeparator } from "@appsmith/ads"; +import { Rename } from "./Rename"; export const ToolbarMenu = () => { const { action } = usePluginActionContext(); @@ -25,21 +26,19 @@ export const ToolbarMenu = () => { ); const isDeletePermitted = getHasDeleteActionPermission( isFeatureEnabled, - action?.userPermissions, + action.userPermissions, ); return ( <> + - {isChangePermitted && ( - <> - - - - )} + + + - {isDeletePermitted && } + ); }; diff --git a/src/pages/Editor/IDE/EditorTabs/AddTab.tsx b/src/pages/Editor/IDE/EditorTabs/AddTab.tsx index bd961e01fd12..0cb19dbd8722 100644 --- a/src/pages/Editor/IDE/EditorTabs/AddTab.tsx +++ b/src/pages/Editor/IDE/EditorTabs/AddTab.tsx @@ -25,13 +25,17 @@ const AddTab = ({ onClose(); }; + const content = `New ${segment === EditorEntityTab.JS ? "JS" : "Query"}`; + return ( onCloseClick(e)} - title={`New ${segment === EditorEntityTab.JS ? "JS" : "Query"}`} - /> + title={content} + > + {content} + ); }; diff --git a/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx b/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx index c27e00a4e3f9..f44f5bbfb117 100644 --- a/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx +++ b/src/pages/Editor/IDE/EditorTabs/EditableTab.tsx @@ -1,12 +1,11 @@ -import React, { useMemo } from "react"; +import React, { useCallback } from "react"; -import { FileTab, type FileTabProps } from "IDE/Components/FileTab"; -import { useNameEditor } from "utils/hooks/useNameEditor"; +import { FileTab } from "IDE/Components/FileTab"; import { type EntityItem } from "ee/entities/IDE/constants"; import { useCurrentEditorState } from "../hooks"; import { useSelector } from "react-redux"; -import { useEventCallback } from "usehooks-ts"; +import { useBoolean, useEventCallback } from "usehooks-ts"; import { getIsSavingEntityName } from "ee/selectors/entitiesSelector"; import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; @@ -14,11 +13,18 @@ import { getEditableTabPermissions, saveEntityName, } from "ee/entities/IDE/utils"; +import { noop } from "lodash"; +import { EditableName } from "IDE"; +import { IconContainer } from "IDE/Components/FileTab/styles"; -interface EditableTabProps extends Omit { +interface EditableTabProps { id: string; + onClick: () => void; onClose: (id: string) => void; entity?: EntityItem; + icon?: React.ReactNode; + isActive: boolean; + title: string; } export function EditableTab(props: EditableTabProps) { @@ -31,40 +37,47 @@ export function EditableTab(props: EditableTabProps) { entity, }); - const { handleNameSave, normalizeName, validateName } = useNameEditor({ - entityId: id, - entityName: title, - nameSaveAction: (params) => saveEntityName({ params, segment, entity }), - }); + const { + setFalse: exitEditMode, + setTrue: enterEditMode, + value: isEditing, + } = useBoolean(false); const isLoading = useSelector((state) => getIsSavingEntityName(state, { id, segment, entity }), ); - const editorConfig = useMemo( - () => ({ - onTitleSave: handleNameSave, - validateTitle: validateName, - titleTransformer: normalizeName, - }), - [handleNameSave, normalizeName, validateName], - ); - const handleClose = useEventCallback((e: React.MouseEvent) => { e.stopPropagation(); onClose(id); }); + const handleDoubleClick = isChangePermitted ? enterEditMode : noop; + + const handleNameSave = useCallback( + (name: string) => { + saveEntityName({ params: { id, name }, segment, entity }); + exitEditMode(); + }, + [entity, exitEditMode, id, segment], + ); + return ( + > + {icon}} + isEditing={isEditing} + isLoading={isLoading} + name={title} + onNameSave={handleNameSave} + /> + ); } diff --git a/src/pages/Editor/JSEditor/JSEditorToolbar/JSObjectNameEditor/JSObjectNameEditor.tsx b/src/pages/Editor/JSEditor/JSEditorToolbar/JSObjectNameEditor/JSObjectNameEditor.tsx index 68ebcb962952..35771dfa77bd 100644 --- a/src/pages/Editor/JSEditor/JSEditorToolbar/JSObjectNameEditor/JSObjectNameEditor.tsx +++ b/src/pages/Editor/JSEditor/JSEditorToolbar/JSObjectNameEditor/JSObjectNameEditor.tsx @@ -1,15 +1,13 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useSelector } from "react-redux"; +import React, { useCallback, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; import type { ReduxAction } from "ee/constants/ReduxActionConstants"; import { getSavingStatusForJSObjectName } from "selectors/actionSelectors"; import { getAssetUrl } from "ee/utils/airgapHelpers"; -import { Spinner, Text as ADSText, Tooltip, Flex } from "@appsmith/ads"; -import { usePrevious } from "@mantine/hooks"; +import { Text as ADSText, Flex } from "@appsmith/ads"; import styled from "styled-components"; -import { useNameEditor } from "utils/hooks/useNameEditor"; -import { useBoolean, useEventCallback, useEventListener } from "usehooks-ts"; +import { useBoolean } from "usehooks-ts"; import { noop } from "lodash"; import { useParams } from "react-router"; import type { AppState } from "ee/reducers"; @@ -18,6 +16,7 @@ import { getPlugin, } from "ee/selectors/entitiesSelector"; import { JSObjectNameEditor as OldJSObjectNameEditor } from "./old/JSObjectNameEditor"; +import { EditableName } from "IDE"; export interface SaveActionNameParams { id: string; @@ -35,7 +34,7 @@ export const NameWrapper = styled(Flex)` height: 100%; position: relative; font-size: 12px; - color: var(--ads-v2-colors-text-default); + color: var(--ads-v2-color-fg); cursor: pointer; gap: var(--ads-v2-spaces-2); align-items: center; @@ -50,6 +49,7 @@ export const IconContainer = styled.div` align-items: center; justify-content: center; flex-shrink: 0; + img { width: 12px; } @@ -61,7 +61,10 @@ export const Text = styled(ADSText)` font-weight: 500; `; -export const JSObjectNameEditor = (props: JSObjectNameEditorProps) => { +export const JSObjectNameEditor = ({ + disabled, + saveJSObjectName, +}: JSObjectNameEditorProps) => { const params = useParams<{ baseCollectionId?: string; baseQueryId?: string; @@ -81,17 +84,7 @@ export const JSObjectNameEditor = (props: JSObjectNameEditorProps) => { .isSaving, ); - const title = currentJSObjectConfig?.name || ""; - const previousTitle = usePrevious(title); - const [editableTitle, setEditableTitle] = useState(title); - const [validationError, setValidationError] = useState(null); - const inputRef = useRef(null); - - const { handleNameSave, normalizeName, validateName } = useNameEditor({ - entityId: params?.baseCollectionId || "", - entityName: title, - nameSaveAction: props.saveJSObjectName, - }); + const name = currentJSObjectConfig?.name || ""; const { setFalse: exitEditMode, @@ -99,134 +92,55 @@ export const JSObjectNameEditor = (props: JSObjectNameEditorProps) => { value: isEditing, } = useBoolean(false); - const currentTitle = - isEditing || isLoading || title !== editableTitle ? editableTitle : title; - - const handleKeyUp = useEventCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - const nameError = validateName(editableTitle); - - if (nameError === null) { - exitEditMode(); - handleNameSave(editableTitle); - } else { - setValidationError(nameError); - } - } else if (e.key === "Escape") { - exitEditMode(); - setEditableTitle(title); - setValidationError(null); - } else { - setValidationError(null); - } - }, - ); + const handleDoubleClick = disabled ? noop : enterEditMode; - const handleTitleChange = useEventCallback( - (e: React.ChangeEvent) => { - setEditableTitle(normalizeName(e.target.value)); - }, - ); + const dispatch = useDispatch(); - const handleEnterEditMode = useEventCallback(() => { - setEditableTitle(title); - enterEditMode(); - }); - - const handleDoubleClick = props.disabled ? noop : handleEnterEditMode; - - const inputProps = useMemo( - () => ({ - onKeyUp: handleKeyUp, - onChange: handleTitleChange, - autoFocus: true, - style: { - paddingTop: 0, - paddingBottom: 0, - left: -1, - top: -1, - }, - }), - [handleKeyUp, handleTitleChange], - ); - - useEventListener( - "focusout", - function handleFocusOut() { - if (isEditing) { - const nameError = validateName(editableTitle); - - exitEditMode(); - - if (nameError === null) { - handleNameSave(editableTitle); - } else { - setEditableTitle(title); - setValidationError(null); - } + const handleSaveName = useCallback( + (name: string) => { + if (currentJSObjectConfig) { + dispatch(saveJSObjectName({ id: currentJSObjectConfig.id, name })); } }, - inputRef, - ); - - useEffect( - function syncEditableTitle() { - if (!isEditing && previousTitle !== title) { - setEditableTitle(title); - } - }, - [title, previousTitle, isEditing], - ); - - useEffect( - function recaptureFocusInEventOfFocusRetention() { - const input = inputRef.current; - - if (isEditing && input) { - setTimeout(() => { - input.focus(); - }, 200); - } - }, - [isEditing], + [currentJSObjectConfig, saveJSObjectName], ); const isActionRedesignEnabled = useFeatureFlag( FEATURE_FLAG.release_actions_redesign_enabled, ); + const icon = useMemo(() => { + if (!currentPlugin) return null; + + return ( + + {currentPlugin.name} + + ); + }, [currentPlugin]); + if (!isActionRedesignEnabled) { return ( ); } return ( - {currentPlugin && !isLoading ? ( - - {currentPlugin.name} - - ) : null} - {isLoading && } - - - - {currentTitle} - - + ); };