From dfaf7d860be28d6b519920b948db26598b964464 Mon Sep 17 00:00:00 2001 From: Edmundo Ruiz Ghanem <168664+edmundito@users.noreply.github.com> Date: Wed, 5 Oct 2022 12:14:40 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9F=20=F0=9F=8E=89=20Replace=20multili?= =?UTF-8?q?ne=20+=20hidden=20connector=20field=20with=20`SecretTextArea`?= =?UTF-8?q?=20component=20(#16539)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SecretTextArea component * Add TextInputContainer to manage the look and feel of inputs and text area * Update multiline + secret controls to use it * Fix SecretTextArea story * Add .fs-exclude class to SecretTextArea and Password input * Use scss color * Add tests for SecretTextArea component * Update SecretTextArea testIds * Remove specificity in secrettextarea rule * Update type to be compatible with React 18 * Move SecretTextArea and TextInputContainer to components/ui --- .../src/components/ui/Input/Input.tsx | 1 + .../SecretTextArea/SecretTextArea.module.scss | 21 +++++ .../ui/SecretTextArea/SecretTextArea.test.tsx | 85 +++++++++++++++++ .../ui/SecretTextArea/SecretTextArea.tsx | 93 +++++++++++++++++++ .../ui/SecretTextArea/index.stories.tsx | 27 ++++++ .../src/components/ui/SecretTextArea/index.ts | 1 + .../TextInputContainer.module.scss | 73 +++++++++++++++ .../TextInputContainer/TextInputContainer.tsx | 45 +++++++++ .../ui/TextInputContainer/index.stories.tsx | 20 ++++ .../components/ui/TextInputContainer/index.ts | 1 + airbyte-webapp/src/locales/en.json | 1 + .../components/Property/Control.tsx | 10 +- 12 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.module.scss create mode 100644 airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.test.tsx create mode 100644 airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.tsx create mode 100644 airbyte-webapp/src/components/ui/SecretTextArea/index.stories.tsx create mode 100644 airbyte-webapp/src/components/ui/SecretTextArea/index.ts create mode 100644 airbyte-webapp/src/components/ui/TextInputContainer/TextInputContainer.module.scss create mode 100644 airbyte-webapp/src/components/ui/TextInputContainer/TextInputContainer.tsx create mode 100644 airbyte-webapp/src/components/ui/TextInputContainer/index.stories.tsx create mode 100644 airbyte-webapp/src/components/ui/TextInputContainer/index.ts diff --git a/airbyte-webapp/src/components/ui/Input/Input.tsx b/airbyte-webapp/src/components/ui/Input/Input.tsx index 98f458e48bb7..40acaf84d0a8 100644 --- a/airbyte-webapp/src/components/ui/Input/Input.tsx +++ b/airbyte-webapp/src/components/ui/Input/Input.tsx @@ -89,6 +89,7 @@ export const Input: React.FC = ({ light, error, ...props }) => { { [styles.disabled]: props.disabled, [styles.password]: isPassword, + "fs-exclude": isPassword, }, props.className )} diff --git a/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.module.scss b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.module.scss new file mode 100644 index 000000000000..53a715d88d95 --- /dev/null +++ b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.module.scss @@ -0,0 +1,21 @@ +@use "../../../scss/colors"; + +.toggleVisibilityButton { + width: 100%; + line-height: 1; + color: colors.$grey-300; + font-style: italic; + margin: 0; + padding: 0; + border: none; + background: none; + cursor: text; +} + +.textarea { + overflow: auto; +} + +.passwordInput { + display: none; +} diff --git a/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.test.tsx b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.test.tsx new file mode 100644 index 000000000000..d97da1346a8f --- /dev/null +++ b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.test.tsx @@ -0,0 +1,85 @@ +import userEvent from "@testing-library/user-event"; +import { act } from "react-dom/test-utils"; +import { render } from "test-utils/testutils"; + +import { SecretTextArea } from "./SecretTextArea"; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const emptyFn = () => {}; + +describe("SecretTextArea", () => { + it("renders textarea when there is no initial value", async () => { + const { queryByTestId, container } = await render(); + + expect(container.querySelector("textarea")).toBeInTheDocument(); + expect(queryByTestId("secretTextArea-visibilityButton")).not.toBeInTheDocument(); + expect(container.querySelector('input[type="password"]')).not.toBeInTheDocument(); + }); + + it("renders on hidden input when there is an initial value", async () => { + const value = "Here is my secret text"; + const { getByTestId, queryByTestId, container } = await render(); + + expect(container.querySelector("textarea")).not.toBeInTheDocument(); + expect(queryByTestId("secretTextArea-visibilityButton")).toBeInTheDocument(); + + const input = getByTestId("secretTextArea-input"); + expect(input).toHaveAttribute("type", "password"); + expect(input).toHaveAttribute("aria-hidden"); + expect(input).toHaveValue(value); + }); + + it("renders disabled when disabled is set", async () => { + const { getByTestId } = await render(); + + expect(getByTestId("textInputContainer")).toHaveClass("disabled"); + expect(getByTestId("secretTextArea-textarea")).toBeDisabled(); + }); + + it("renders disabled when disabled is set and with initial value", async () => { + const value = "Here is my secret text"; + const { getByTestId } = await render(); + + expect(getByTestId("textInputContainer")).toHaveClass("disabled"); + expect(getByTestId("secretTextArea-visibilityButton")).toBeDisabled(); + }); + + it("calls onChange handler when typing", async () => { + const onChange = jest.fn(); + const value = "Here is my secret text"; + const { getByTestId } = await render(); + + const textarea = getByTestId("secretTextArea-textarea"); + + userEvent.type(textarea, value); + + expect(onChange).toBeCalledTimes(value.length); + }); + + it("renders on textarea when clicked visibility button", async () => { + const value = "Here is my secret text"; + const { getByTestId, container } = await render(); + + userEvent.click(getByTestId("secretTextArea-visibilityButton")); + + expect(getByTestId("secretTextArea-textarea")).toHaveFocus(); + expect(getByTestId("secretTextArea-textarea")).toHaveValue(value); + expect(container.querySelector('input[type="password"]')).not.toBeInTheDocument(); + }); + + it("renders on password input when clicking away from visibility area", async () => { + const value = "Here is my secret text"; + const { queryByTestId, getByTestId, container } = await render(); + + userEvent.click(getByTestId("secretTextArea-visibilityButton")); + expect(getByTestId("secretTextArea-textarea")).toHaveFocus(); + + act(() => { + getByTestId("secretTextArea-textarea").blur(); + }); + + expect(container.querySelector("textarea")).not.toBeInTheDocument(); + expect(queryByTestId("secretTextArea-visibilityButton")).toBeInTheDocument(); + expect(container.querySelector('input[type="password"]')).toHaveValue(value); + }); +}); diff --git a/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.tsx b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.tsx new file mode 100644 index 000000000000..90aaa4fbbe20 --- /dev/null +++ b/airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.tsx @@ -0,0 +1,93 @@ +import { faEye } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import classNames from "classnames"; +import { useMemo, useRef } from "react"; +import { FormattedMessage } from "react-intl"; +import { useToggle, useUpdateEffect } from "react-use"; + +import { TextInputContainer, TextInputContainerProps } from "../TextInputContainer"; +import styles from "./SecretTextArea.module.scss"; + +interface SecretTextAreaProps + extends Omit, + React.TextareaHTMLAttributes {} + +export const SecretTextArea: React.FC = ({ + name, + disabled, + value, + onMouseUp, + onBlur, + error, + light, + ...textAreaProps +}) => { + const hasValue = useMemo(() => !!value && String(value).trim().length > 0, [value]); + const [isContentVisible, toggleIsContentVisible] = useToggle(!hasValue); + const textAreaRef = useRef(null); + const textAreaHeightRef = useRef((textAreaProps.rows ?? 1) * 20 + 14); + + useUpdateEffect(() => { + if (isContentVisible && textAreaRef.current) { + textAreaRef.current.focus(); + const selectionStart = value ? String(value).length : 0; + textAreaRef.current.setSelectionRange(selectionStart, selectionStart); + } + }, [isContentVisible]); + + return ( + + {isContentVisible ? ( +