diff --git a/airbyte-webapp/src/components/base/Input/Input.test.tsx b/airbyte-webapp/src/components/base/Input/Input.test.tsx new file mode 100644 index 000000000000..d41105391223 --- /dev/null +++ b/airbyte-webapp/src/components/base/Input/Input.test.tsx @@ -0,0 +1,44 @@ +import { render } from "utils/testutils"; + +import { Input } from "./Input"; + +describe("", () => { + test("renders text input", async () => { + const value = "aribyte@example.com"; + const { getByTestId, queryByTestId } = await render(); + + expect(getByTestId("input")).toHaveAttribute("type", "text"); + expect(getByTestId("input")).toHaveValue(value); + expect(queryByTestId("toggle-password-visibility-button")).toBeFalsy(); + }); + + test("renders another type of input", async () => { + const type = "number"; + const value = 888; + const { getByTestId, queryByTestId } = await render(); + + expect(getByTestId("input")).toHaveAttribute("type", type); + expect(getByTestId("input")).toHaveValue(value); + expect(queryByTestId("toggle-password-visibility-button")).toBeFalsy(); + }); + + test("renders password input with visibilty button", async () => { + const value = "eight888"; + const { getByTestId, getByRole } = await render(); + + expect(getByTestId("input")).toHaveAttribute("type", "password"); + expect(getByTestId("input")).toHaveValue(value); + expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye"); + }); + + test("renders visible password when visibility button is clicked", async () => { + const value = "eight888"; + const { getByTestId, getByRole } = await render(); + + getByTestId("toggle-password-visibility-button")?.click(); + + expect(getByTestId("input")).toHaveAttribute("type", "text"); + expect(getByTestId("input")).toHaveValue(value); + expect(getByRole("img", { hidden: true })).toHaveAttribute("data-icon", "eye-slash"); + }); +}); diff --git a/airbyte-webapp/src/components/base/Input/Input.tsx b/airbyte-webapp/src/components/base/Input/Input.tsx index 796d12fa880c..117ff76e206a 100644 --- a/airbyte-webapp/src/components/base/Input/Input.tsx +++ b/airbyte-webapp/src/components/base/Input/Input.tsx @@ -1,6 +1,8 @@ import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { useState } from "react"; +import React from "react"; +import { useIntl } from "react-intl"; +import { useToggle } from "react-use"; import styled from "styled-components"; import { Theme } from "theme"; @@ -18,37 +20,44 @@ const getBackgroundColor = (props: IStyleProps) => { return props.theme.greyColor0; }; -export type InputProps = { +export interface InputProps extends React.InputHTMLAttributes { error?: boolean; light?: boolean; -} & React.InputHTMLAttributes; +} -const InputComponent = styled.input` - outline: none; +const InputContainer = styled.div` width: 100%; - padding: 7px 18px 7px 8px; - border-radius: 4px; - font-size: 14px; - line-height: 20px; - font-weight: normal; - border: 1px solid ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor0)}; + position: relative; background: ${(props) => getBackgroundColor(props)}; - color: ${({ theme }) => theme.textColor}; - caret-color: ${({ theme }) => theme.primaryColor}; - - &::placeholder { - color: ${({ theme }) => theme.greyColor40}; - } + border: 1px solid ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor0)}; + border-radius: 4px; &:hover { background: ${({ theme, light }) => (light ? theme.whiteColor : theme.greyColor20)}; border-color: ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor20)}; } - &:focus { + &.input-container--focused { background: ${({ theme, light }) => (light ? theme.whiteColor : theme.primaryColor12)}; border-color: ${({ theme }) => theme.primaryColor}; } +`; + +const InputComponent = styled.input` + outline: none; + width: ${({ isPassword }) => (isPassword ? "calc(100% - 22px)" : "100%")}; + padding: 7px 8px 7px 8px; + font-size: 14px; + line-height: 20px; + font-weight: normal; + border: none; + background: none; + color: ${({ theme }) => theme.textColor}; + caret-color: ${({ theme }) => theme.primaryColor}; + + &::placeholder { + color: ${({ theme }) => theme.greyColor40}; + } &:disabled { pointer-events: none; @@ -56,34 +65,53 @@ const InputComponent = styled.input` } `; -const Container = styled.div` - width: 100%; - position: relative; -`; - const VisibilityButton = styled(Button)` position: absolute; - right: 2px; - top: 7px; + right: 0px; + top: 0; + display: flex; + height: 100%; + width: 30px; + align-items: center; + justify-content: center; + border: none; `; const Input: React.FC = (props) => { - const [isContentVisible, setIsContentVisible] = useState(false); - - if (props.type === "password") { - return ( - - - {props.disabled ? null : ( - setIsContentVisible(!isContentVisible)} type="button"> - - - )} - - ); - } - - return ; + const { formatMessage } = useIntl(); + const [isContentVisible, setIsContentVisible] = useToggle(false); + const [focused, toggleFocused] = useToggle(false); + + const isPassword = props.type === "password"; + const isVisibilityButtonVisible = isPassword && !props.disabled; + const type = isPassword ? (isContentVisible ? "text" : "password") : props.type; + const onInputFocusChange = () => toggleFocused(); + + return ( + + + {isVisibilityButtonVisible ? ( + setIsContentVisible()} + type="button" + aria-label={formatMessage({ + id: `ui.input.${isContentVisible ? "hide" : "show"}Password`, + })} + data-testid="toggle-password-visibility-button" + > + + + ) : null} + + ); }; export default Input; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index 2c1378b5b4ad..9844a505e666 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -487,6 +487,8 @@ "errorView.notFound": "Resource not found", "errorView.unknown": "Unknown", + "ui.input.showPassword": "Show password", + "ui.input.hidePassword": "Hide password", "ui.keyValuePair": "{key}: {value}", "ui.keyValuePairV2": "{key} ({value})", "ui.keyValuePairV3": "{key}, {value}", diff --git a/airbyte-webapp/src/utils/testutils.tsx b/airbyte-webapp/src/utils/testutils.tsx index ab6a2f354d14..9f9e50e70fc9 100644 --- a/airbyte-webapp/src/utils/testutils.tsx +++ b/airbyte-webapp/src/utils/testutils.tsx @@ -1,5 +1,4 @@ -import { act, Queries, render as rtlRender, RenderResult } from "@testing-library/react"; -import { History } from "history"; +import { act, Queries, queries, render as rtlRender, RenderOptions, RenderResult } from "@testing-library/react"; import React from "react"; import { IntlProvider } from "react-intl"; import { MemoryRouter } from "react-router-dom"; @@ -9,20 +8,14 @@ import { configContext, defaultConfig } from "config"; import { FeatureService } from "hooks/services/Feature"; import en from "locales/en.json"; -export type RenderOptions = { - // optionally pass in a history object to control routes in the test - history?: History; - container?: HTMLElement; -}; - type WrapperProps = { - children?: React.ReactNode; + children?: React.ReactElement; }; -export async function render( - ui: React.ReactNode, - renderOptions?: RenderOptions -): Promise> { +export async function render< + Q extends Queries = typeof queries, + Container extends Element | DocumentFragment = HTMLElement +>(ui: React.ReactNode, renderOptions?: RenderOptions): Promise> { function Wrapper({ children }: WrapperProps) { return ( @@ -35,9 +28,9 @@ export async function render( ); } - let renderResult: RenderResult; + let renderResult: RenderResult; await act(async () => { - renderResult = await rtlRender(
{ui}
, { wrapper: Wrapper, ...renderOptions }); + renderResult = await rtlRender(
{ui}
, { wrapper: Wrapper, ...renderOptions }); }); return renderResult!;