Skip to content

Commit

Permalink
chore: Add Rename context menu (#37116)
Browse files Browse the repository at this point in the history
  • Loading branch information
hetunandu authored Oct 31, 2024
1 parent a647668 commit ac9e101
Show file tree
Hide file tree
Showing 19 changed files with 234 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,10 @@ const ExternalLinkIcon = importRemixIcon(
async () => import("remixicon-react/ExternalLinkLineIcon"),
);

const InputCursorMoveIcon = importSvg(
async () => import("../__assets__/icons/ads/input-cursor-move.svg"),
);

import PlayIconPNG from "../__assets__/icons/control/play-icon.png";

function PlayIconPNGWrapper() {
Expand Down Expand Up @@ -1363,6 +1367,7 @@ const ICON_LOOKUP = {
"minimize-v3": MinimizeV3Icon,
"maximize-v3": MaximizeV3Icon,
"workflows-mono": WorkflowsMonochromeIcon,
"input-cursor-move": InputCursorMoveIcon,
billing: BillingIcon,
binding: Binding,
book: BookIcon,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 5 additions & 9 deletions app/client/src/IDE/Components/EditableName/EditableName.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,13 @@ describe("EditableName", () => {
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(getByRole("tooltip").textContent).toEqual(validationError);

expect(exitEditing).toHaveBeenCalled();
expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle);
Expand All @@ -169,7 +167,6 @@ describe("EditableName", () => {
target: { value: invalidTitle },
});

fireEvent.keyUp(inputElement, KEY_CONFIG.ENTER);
fireEvent.keyUp(inputElement, KEY_CONFIG.ESC);

expect(getByRole("tooltip")).toBeInTheDocument();
Expand All @@ -189,9 +186,8 @@ describe("EditableName", () => {
target: { value: invalidTitle },
});

fireEvent.keyUp(inputElement, KEY_CONFIG.ENTER);
fireEvent.focusOut(inputElement);
expect(getByRole("tooltip").textContent).toEqual("");
expect(getByRole("tooltip").textContent).toEqual(validationError);
expect(exitEditing).toHaveBeenCalled();
expect(onNameSave).not.toHaveBeenCalledWith(invalidTitle);
});
Expand All @@ -201,12 +197,12 @@ describe("EditableName", () => {
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",
);
fireEvent.keyUp(input, KEY_CONFIG.ENTER);

expect(onNameSave).not.toHaveBeenCalledWith("");
});
});
});
91 changes: 64 additions & 27 deletions app/client/src/IDE/Components/EditableName/EditableName.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
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 true will show a spinner **/
isLoading?: boolean;
/** if a valid name is entered, the onNameSave
* will be called with the new name */
onNameSave: (name: string) => void;
/** Used in conjunction with exit editing to control
* this component input editable state */
isEditing: boolean;
/** Used in conjunction with exit editing to control this component
* input editable state This function will be called when the
* user is trying to exit the editing mode **/
exitEditing: () => void;
/** Icon is replaced by spinner when isLoading is shown */
icon: React.ReactNode;
inputTestId?: string;
}
Expand All @@ -32,30 +47,61 @@ export const EditableName = ({
entityName: name,
});

const exitWithoutSaving = useCallback(() => {
exitEditing();
setEditableName(name);
setValidationError(null);
}, [exitEditing, name]);

const validate = useCallback(
(name: string) => {
const nameError = validateName(name);

if (nameError === null) {
setValidationError(null);
} else {
setValidationError(nameError);
}

return nameError;
},
[validateName],
);

const attemptSave = useCallback(() => {
const nameError = validate(editableName);

if (editableName === name) {
exitWithoutSaving();
} else if (nameError === null) {
exitEditing();
onNameSave(editableName);
}
}, [
editableName,
exitEditing,
exitWithoutSaving,
name,
onNameSave,
validate,
]);

const handleKeyUp = useEventCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const nameError = validateName(editableName);

if (nameError === null) {
exitEditing();
onNameSave(editableName);
} else {
setValidationError(nameError);
}
attemptSave();
} else if (e.key === "Escape") {
exitEditing();
setEditableName(name);
setValidationError(null);
} else {
setValidationError(null);
exitWithoutSaving();
}
},
);

const handleTitleChange = useEventCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setEditableName(normalizeName(e.target.value));
const value = normalizeName(e.target.value);

setEditableName(value);
validate(value);
},
);

Expand All @@ -67,23 +113,14 @@ export const EditableName = ({
autoFocus: true,
style: { paddingTop: 0, paddingBottom: 0, left: -1, top: -1 },
}),
[handleKeyUp, handleTitleChange],
[handleKeyUp, handleTitleChange, inputTestId],
);

useEventListener(
"focusout",
function handleFocusOut() {
if (isEditing) {
const nameError = validateName(editableName);

exitEditing();

if (nameError === null) {
onNameSave(editableName);
} else {
setEditableName(name);
setValidationError(null);
}
attemptSave();
}
},
inputRef,
Expand Down Expand Up @@ -120,9 +157,9 @@ export const EditableName = ({
<Tooltip content={validationError} visible={Boolean(validationError)}>
<Text
inputProps={inputProps}
inputRef={inputRef}
isEditable={isEditing}
kind="body-s"
ref={inputRef}
>
{editableName}
</Text>
Expand Down
30 changes: 30 additions & 0 deletions app/client/src/IDE/Components/EditableName/RenameMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { useCallback } from "react";
import { MenuItem } from "@appsmith/ads";
import { useDispatch } from "react-redux";
import { setRenameEntity } from "actions/ideActions";

interface Props {
disabled?: boolean;
entityId: string;
}

export const RenameMenuItem = ({ disabled, entityId }: Props) => {
const dispatch = useDispatch();

const setRename = useCallback(() => {
// We add a delay to avoid having the focus stuck in the menu trigger
setTimeout(() => {
dispatch(setRenameEntity(entityId));
}, 100);
}, [dispatch, entityId]);

return (
<MenuItem
disabled={disabled}
onSelect={setRename}
startIcon="input-cursor-move"
>
Rename
</MenuItem>
);
};
2 changes: 2 additions & 0 deletions app/client/src/IDE/Components/EditableName/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { EditableName } from "./EditableName";
export { RenameMenuItem } from "./RenameMenuItem";
export { useIsRenaming } from "./useIsRenaming";
35 changes: 35 additions & 0 deletions app/client/src/IDE/Components/EditableName/useIsRenaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useDispatch, useSelector } from "react-redux";
import { getIsRenaming } from "selectors/ideSelectors";
import { useCallback, useEffect, useState } from "react";
import { setRenameEntity } from "actions/ideActions";

export const useIsRenaming = (id: string) => {
const dispatch = useDispatch();
const [isEditing, setIsEditing] = useState(false);

const isEditingViaExternal = useSelector(getIsRenaming(id));

useEffect(
function onExternalEditEvent() {
if (isEditingViaExternal) {
setIsEditing(true);
}

return () => {
setIsEditing(false);
};
},
[isEditingViaExternal],
);

const enterEditMode = useCallback(() => {
setIsEditing(true);
}, []);

const exitEditMode = useCallback(() => {
dispatch(setRenameEntity(""));
setIsEditing(false);
}, [dispatch]);

return { isEditing, enterEditMode, exitEditMode };
};
21 changes: 12 additions & 9 deletions app/client/src/IDE/Components/EditableName/useNameEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
import { shallowEqual, useSelector } from "react-redux";
import type { AppState } from "ee/reducers";
import { getUsedActionNames } from "selectors/actionSelectors";
import { useEventCallback } from "usehooks-ts";
import { isNameValid, removeSpecialChars } from "utils/helpers";
import { useCallback } from "react";

interface UseNameEditorProps {
entityName: string;
Expand All @@ -25,15 +25,18 @@ export function useNameEditor(props: UseNameEditorProps) {
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);
}
const validateName = useCallback(
(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;
});
return null;
},
[entityName, nameErrorMessage, usedEntityNames],
);

return {
validateName,
Expand Down
6 changes: 5 additions & 1 deletion app/client/src/IDE/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ export { ToolbarSettingsPopover } from "./Components/ToolbarSettingsPopover";
* EditableName is a component that allows the user to edit the name of an entity
* It is used in the IDE for renaming pages, actions, queries, etc.
*/
export { EditableName } from "./Components/EditableName";
export {
EditableName,
RenameMenuItem,
useIsRenaming,
} from "./Components/EditableName";

/* ====================================================
**** Interfaces ****
Expand Down
Loading

0 comments on commit ac9e101

Please sign in to comment.