Skip to content

Commit

Permalink
🪟 🎉 Replace multiline + hidden connector field with SecretTextArea …
Browse files Browse the repository at this point in the history
…component (#16539)

* 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
  • Loading branch information
edmundito authored Oct 5, 2022
1 parent cb0cb92 commit dfaf7d8
Show file tree
Hide file tree
Showing 12 changed files with 377 additions and 1 deletion.
1 change: 1 addition & 0 deletions airbyte-webapp/src/components/ui/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const Input: React.FC<InputProps> = ({ light, error, ...props }) => {
{
[styles.disabled]: props.disabled,
[styles.password]: isPassword,
"fs-exclude": isPassword,
},
props.className
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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(<SecretTextArea />);

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(<SecretTextArea value={value} onChange={emptyFn} />);

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(<SecretTextArea disabled />);

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(<SecretTextArea value={value} onChange={emptyFn} disabled />);

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(<SecretTextArea onChange={onChange} />);

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(<SecretTextArea value={value} onChange={emptyFn} />);

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(<SecretTextArea value={value} onChange={emptyFn} />);

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);
});
});
93 changes: 93 additions & 0 deletions airbyte-webapp/src/components/ui/SecretTextArea/SecretTextArea.tsx
Original file line number Diff line number Diff line change
@@ -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<TextInputContainerProps, "onFocus" | "onBlur">,
React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

export const SecretTextArea: React.FC<SecretTextAreaProps> = ({
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<HTMLTextAreaElement | null>(null);
const textAreaHeightRef = useRef<number>((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 (
<TextInputContainer disabled={disabled} error={error} light={light}>
{isContentVisible ? (
<textarea
spellCheck={false}
{...textAreaProps}
className={classNames(styles.textarea, "fs-exclude", textAreaProps.className)}
name={name}
disabled={disabled}
ref={textAreaRef}
onMouseUp={(event) => {
textAreaHeightRef.current = textAreaRef.current?.offsetHeight ?? textAreaHeightRef.current;
onMouseUp?.(event);
}}
onBlur={(event) => {
textAreaHeightRef.current = textAreaRef.current?.offsetHeight ?? textAreaHeightRef.current;
if (hasValue) {
toggleIsContentVisible();
}
onBlur?.(event);
}}
style={{ height: textAreaHeightRef.current }}
value={value}
data-testid="secretTextArea-textarea"
/>
) : (
<>
<button
type="button"
className={styles.toggleVisibilityButton}
onClick={() => {
toggleIsContentVisible();
}}
style={{
height: textAreaHeightRef.current,
}}
disabled={disabled}
data-testid="secretTextArea-visibilityButton"
>
<FontAwesomeIcon icon={faEye} fixedWidth /> <FormattedMessage id="ui.secretTextArea.hidden" />
</button>
<input
type="password"
name={name}
disabled
value={value}
className={styles.passwordInput}
readOnly
aria-hidden
data-testid="secretTextArea-input"
/>
</>
)}
</TextInputContainer>
);
};
27 changes: 27 additions & 0 deletions airbyte-webapp/src/components/ui/SecretTextArea/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";

import { SecretTextArea } from "./SecretTextArea";

export default {
title: "UI/SecretTextArea",
component: SecretTextArea,
argTypes: {
value: { control: { type: { name: "text", required: false } } },
rows: { control: { type: { name: "number", required: false } } },
},
} as ComponentMeta<typeof SecretTextArea>;

const Template: ComponentStory<typeof SecretTextArea> = (args) => (
<SecretTextArea
{...args}
onChange={() => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
}}
/>
);

export const Primary = Template.bind({});
Primary.args = {
rows: 1,
value: "testing",
};
1 change: 1 addition & 0 deletions airbyte-webapp/src/components/ui/SecretTextArea/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./SecretTextArea";
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
@use "../../../scss/colors";
@use "../../../scss/variables";

.container {
width: 100%;
position: relative;
background-color: colors.$grey-50;
border: 1px solid colors.$grey-50;
border-radius: 4px;

&.light {
background-color: colors.$white;
}

&.error {
background-color: colors.$grey-100;
border-color: colors.$red;
}

&:not(.disabled, .focused):hover {
background-color: colors.$grey-100;
border-color: colors.$grey-100;

&.light {
background-color: colors.$white;
}

&.error {
border-color: colors.$red;
}
}

&.focused {
background-color: colors.$primaryColor12;
border-color: colors.$blue;

&.light {
background-color: colors.$white;
}
}

& > input,
textarea {
outline: none;
width: 100%;
padding: 7px 8px;
margin: 0;
line-height: 20px;
font-size: 14px;
font-weight: normal;
border: none;
background: none;
color: colors.$dark-blue;
caret-color: colors.$blue;

&::placeholder {
color: colors.$grey-300;
}
}

& > textarea {
resize: vertical;
display: inherit;
}

.disabled {
& > input,
textarea {
pointer-events: none;
color: colors.$grey-400;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import classNames from "classnames";
import { useState } from "react";

import styles from "./TextInputContainer.module.scss";

export interface TextInputContainerProps {
disabled?: boolean;
light?: boolean;
error?: boolean;
onFocus?: React.FocusEventHandler<HTMLDivElement>;
onBlur?: React.FocusEventHandler<HTMLDivElement>;
}

export const TextInputContainer: React.FC<TextInputContainerProps> = ({
disabled,
light,
error,
onFocus,
onBlur,
children,
}) => {
const [focused, setFocused] = useState(false);

return (
<div
className={classNames(styles.container, {
[styles.disabled]: disabled,
[styles.focused]: focused,
[styles.light]: light,
[styles.error]: error,
})}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
data-testid="textInputContainer"
>
{children}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";

import { TextInputContainer } from "./TextInputContainer";

export default {
title: "Ui/TextInputContainer",
component: TextInputContainer,
} as ComponentMeta<typeof TextInputContainer>;

const Template: ComponentStory<typeof TextInputContainer> = (args) => <TextInputContainer {...args} />;

export const WithInput = Template.bind({});
WithInput.args = {
children: <input type="text" placeholder="With text..." />,
};

export const WithTextArea = Template.bind({});
WithTextArea.args = {
children: <textarea placeholder="With textarea..." />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./TextInputContainer";
1 change: 1 addition & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@
"ui.keyValuePairV2": "{key} ({value})",
"ui.keyValuePairV3": "{key}, {value}",
"ui.learnMore": "Learn more",
"ui.secretTextArea.hidden": "Contents hidden. Click to show.",

"airbyte.datatype.string": "String",
"airbyte.datatype.date": "Date",
Expand Down
Loading

0 comments on commit dfaf7d8

Please sign in to comment.