Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Add Rename context menu #37116

Merged
merged 21 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading