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!;