Skip to content

Commit

Permalink
chore: Editable name refactor (#37069)
Browse files Browse the repository at this point in the history
## Description

Refactors the ADS Text edit capabilities into a single Editable Name
component for use of entity name edit. This is currently used in Tabs
and Toolbars.


Fixes #37086

## Automation

/ok-to-test tags="@tag.IDE"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/11513426772>
> Commit: 86ec8d3
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=11513426772&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.IDE`
> Spec:
> <hr>Fri, 25 Oct 2024 07:33:47 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [x] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Release Notes

- **New Features**
- Introduced the `EditableName` component for editing names with
validation and loading states.
	- Added a `Rename` component to the toolbar for renaming tasks.
- Enhanced `ToolbarMenu` to include `Copy`, `Move`, and `Delete`
components with configurable disabled states.

- **Improvements**
- Streamlined the `PluginActionNameEditor` and `JSObjectNameEditor`
components to utilize the new `EditableName` component for editing
functionality.
- Simplified the `FileTab` and `EditableTab` components, focusing on
static content rendering.
- Updated the `AddTab` component to dynamically render titles based on
state.

- **Bug Fixes**
- Improved handling of user interactions and validation errors in the
`EditableName` component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
  • Loading branch information
hetunandu authored Oct 28, 2024
1 parent 437f689 commit 836bf20
Show file tree
Hide file tree
Showing 16 changed files with 521 additions and 642 deletions.
212 changes: 212 additions & 0 deletions app/client/src/IDE/Components/EditableName/EditableName.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => <Icon name="js" />;
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: <TabIcon />,
isEditing,
onNameSave: mockOnNameSave,
exitEditing: mockOnExitEditing,
isLoading,
};

// Render the component
const utils = render(<EditableName {...props} />);

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",
);
});
});
});
132 changes: 132 additions & 0 deletions app/client/src/IDE/Components/EditableName/EditableName.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);

const { normalizeName, validateName } = useNameEditor({
entityName: name,
});

const handleKeyUp = useEventCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 ? <Spinner size="sm" /> : icon}
<Tooltip content={validationError} visible={Boolean(validationError)}>
<Text
inputProps={inputProps}
isEditable={isEditing}
kind="body-s"
ref={inputRef}
>
{editableName}
</Text>
</Tooltip>
</>
);
};
1 change: 1 addition & 0 deletions app/client/src/IDE/Components/EditableName/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { EditableName } from "./EditableName";
Loading

0 comments on commit 836bf20

Please sign in to comment.