Skip to content

Commit

Permalink
🪟 New <TagInput /> (#17760)
Browse files Browse the repository at this point in the history
* WIP add creatable

* appease the compiler

* working on it

* more work

* adding and removing tags works

* handle delimiters

* component works, testing WIP

* mostly working, add tests

* cleanup tests

* styling of tag items

* note to self

* cleanup

* cleanup some more, delete old components

* update style exports and render method

* taginput into a describe block

* find the tag input in service form tests

* cleanup

* use simplified queryselector

* Update airbyte-webapp/src/components/ui/TagInput/TagInput.module.scss

Co-authored-by: Vladimir <volodymyr.s.petrov@globallogic.com>

Co-authored-by: Vladimir <volodymyr.s.petrov@globallogic.com>
  • Loading branch information
teallarson and dizel852 authored Oct 14, 2022
1 parent 1d2a4ba commit d2d8989
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 234 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@use "scss/colors";
@use "scss/variables";

:export {
backgroundColor: colors.$dark-blue-800;
fontColor: colors.$white;
borderRadius: variables.$border-radius-sm;
paddingLeft: variables.$spacing-sm;
}
95 changes: 95 additions & 0 deletions airbyte-webapp/src/components/ui/TagInput/TagInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";

import { TagInput } from "./TagInput";

const TagInputWithWrapper = () => {
const [fieldValue, setFieldValue] = useState(["tag1", "tag2"]);
return <TagInput name="test" fieldValue={fieldValue} onChange={(values) => setFieldValue(values)} disabled={false} />;
};

describe("<TagInput />", () => {
it("renders with defaultValue", () => {
render(<TagInputWithWrapper />);
const tag1 = screen.getByText("tag1");
const tag2 = screen.getByText("tag2");
expect(tag1).toBeInTheDocument();
expect(tag2).toBeInTheDocument();
});

describe("delimiters and keypress events create tags", () => {
it("adds a tag when user types a tag and hits enter", () => {
render(<TagInputWithWrapper />);
const input = screen.getByRole("combobox");
userEvent.type(input, "tag3{enter}");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
});
it("adds a tag when user types a tag and hits tab", () => {
render(<TagInputWithWrapper />);
const input = screen.getByRole("combobox");
userEvent.type(input, "tag3{Tab}");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
});

it("adds multiple tags when a user enters a string with commas", () => {
render(<TagInputWithWrapper />);
const input = screen.getByRole("combobox");
userEvent.type(input, "tag3, tag4,");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
const tag4 = screen.getByText("tag4");
expect(tag4).toBeInTheDocument();
});
it("adds multiple tags when a user enters a string with semicolons", () => {
render(<TagInputWithWrapper />);
const input = screen.getByRole("combobox");
userEvent.type(input, "tag3; tag4;");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
const tag4 = screen.getByText("tag4");
expect(tag4).toBeInTheDocument();
});
it("handles a combination of methods at once", () => {
render(<TagInputWithWrapper />);
const input = screen.getByRole("combobox");
userEvent.type(input, "tag3; tag4{Tab} tag5, tag6{enter}");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
const tag4 = screen.getByText("tag4");
expect(tag4).toBeInTheDocument();
const tag5 = screen.getByText("tag5");
expect(tag5).toBeInTheDocument();
const tag6 = screen.getByText("tag6");
expect(tag6).toBeInTheDocument();
});
});

it("correctly removes a tag when user clicks its Remove button", () => {
render(<TagInputWithWrapper />);
const tag1 = screen.getByText("tag1");
expect(tag1).toBeInTheDocument();

const tag2 = screen.getByText("tag2");
expect(tag2).toBeInTheDocument();

const input = screen.getByRole("combobox");
userEvent.type(input, "tag3{enter}");
const tag3 = screen.getByText("tag3");
expect(tag3).toBeInTheDocument();
const removeTag2Button = screen.getByRole("button", { name: "Remove tag2" });
userEvent.click(removeTag2Button);

const tag1again = screen.getByText("tag1");
expect(tag1again).toBeInTheDocument();

// queryBy because getBy will throw if not in the DOM
const tag2again = screen.queryByText("tag2");
expect(tag2again).not.toBeInTheDocument();

const tag3again = screen.getByText("tag3");
expect(tag3again).toBeInTheDocument();
});
});
265 changes: 115 additions & 150 deletions airbyte-webapp/src/components/ui/TagInput/TagInput.tsx
Original file line number Diff line number Diff line change
@@ -1,171 +1,136 @@
import React, { useRef, useState } from "react";
import styled from "styled-components";

import { TagItem, IItemProps } from "./TagItem";

const MainContainer = styled.div<{ error?: boolean; disabled?: boolean }>`
width: 100%;
min-height: 36px;
border-radius: 4px;
padding: 6px 6px 0;
cursor: text;
max-height: 100%;
overflow: auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-self: stretch;
border: 1px solid ${(props) => (props.error ? props.theme.dangerColor : props.theme.greyColor0)};
background: ${(props) => (props.error ? props.theme.greyColor10 : props.theme.greyColor0)};
caret-color: ${({ theme }) => theme.primaryColor};
${({ disabled, theme, error }) =>
!disabled &&
`
&:hover {
background: ${theme.greyColor20};
border-color: ${error ? theme.dangerColor : theme.greyColor20};
}
`}
&:focus,
&:focus-within {
background: ${({ theme }) => theme.primaryColor12};
border-color: ${({ theme }) => theme.primaryColor};
}
`;

const InputElement = styled.input`
margin-bottom: 4px;
border: none;
outline: none;
font-size: 14px;
line-height: 20px;
font-weight: normal;
color: ${({ theme }) => theme.textColor};
flex: 1 1 auto;
background: rgba(0, 0, 0, 0);
&::placeholder {
color: ${({ theme }) => theme.greyColor40};
}
`;

export interface TagInputProps {
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
value: IItemProps[];
className?: string;
validationRegex?: RegExp;
import { uniqueId } from "lodash";
import { KeyboardEventHandler, useMemo, useState } from "react";
import { ActionMeta, MultiValue, OnChangeValue } from "react-select";
import CreatableSelect from "react-select/creatable";

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

const components = {
DropdownIndicator: null,
};

const customStyles = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- react-select's typing is lacking here
multiValue: (provided: any) => ({
...provided,
maxWidth: "100%",
display: "flex",
background: `${styles.backgroundColor}`,
color: `${styles.fontColor}`,
borderRadius: `${styles.borderRadius}`,
paddingLeft: `${styles.paddingLeft}`,
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same as above
multiValueLabel: (provided: any) => ({
...provided,
color: `${styles.fontColor}`,
fontWeight: 500,
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same as above
multiValueRemove: (provided: any) => ({
...provided,
borderRadius: `${styles.borderRadius}`,
}),
};

interface Tag {
readonly label: string;
readonly value: string;
}

interface TagInputProps {
name: string;
fieldValue: string[];
onChange: (value: string[]) => void;
error?: boolean;
addOnBlur?: boolean;
disabled?: boolean;
name?: string;

onEnter: (value?: string | number | readonly string[]) => void;
onDelete: (value: string) => void;
onError?: () => void;
id?: string;
}

export const TagInput: React.FC<TagInputProps> = ({
inputProps,
onEnter,
value,
className,
onDelete,
validationRegex,
error,
disabled,
onError,
addOnBlur,
name,
}) => {
const inputElement = useRef<HTMLInputElement | null>(null);
const [selectedElementId, setSelectedElementId] = useState("");
const [currentInputValue, setCurrentInputValue] = useState("");

const handleContainerBlur = () => setSelectedElementId("");
const handleContainerClick = () => {
if (inputElement.current !== null) {
inputElement.current.focus();
}
};
const generateTagFromString = (inputValue: string): Tag => ({
label: inputValue,
value: uniqueId(`tag_value_`),
});

const onAddValue = () => {
if (!inputElement.current?.value) {
return;
}
const generateStringFromTag = (tag: Tag): string => tag.label;

const delimiters = [",", ";"];

const isValid = validationRegex ? !!inputElement.current?.value.match(validationRegex) : true;
export const TagInput: React.FC<TagInputProps> = ({ onChange, fieldValue, name, disabled, id }) => {
const tags = useMemo(() => fieldValue.map(generateTagFromString), [fieldValue]);

if (isValid) {
onEnter(currentInputValue);
setCurrentInputValue("");
} else if (onError) {
onError();
// input value is a tag draft
const [inputValue, setInputValue] = useState("");

// handles various ways of deleting a value
const handleDelete = (_value: OnChangeValue<Tag, true>, actionMeta: ActionMeta<Tag>) => {
let updatedTags: MultiValue<Tag> = tags;

/**
* remove-value: user clicked x or used backspace/delete to remove tag
* clear: user clicked big x to clear all tags
* pop-value: user clicked backspace to remove tag
*/
if (actionMeta.action === "remove-value") {
updatedTags = updatedTags.filter((tag) => tag.value !== actionMeta.removedValue.value);
} else if (actionMeta.action === "clear") {
updatedTags = [];
} else if (actionMeta.action === "pop-value") {
updatedTags = updatedTags.slice(0, updatedTags.length - 1);
}
onChange(updatedTags.map((tag) => generateStringFromTag(tag)));
};

const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { keyCode } = event;

// on ENTER click
if (keyCode === 13) {
event.stopPropagation();
event.preventDefault();
onAddValue();

// on DELETE or BACKSPACE click when input is empty (select or delete last tag in valuesList)
} else if ((keyCode === 46 || keyCode === 8) && currentInputValue === "") {
if (selectedElementId !== "") {
const nextId = value.length - 1 > 0 ? value[value.length - 2].id : "";
onDelete(selectedElementId);
setSelectedElementId(nextId);
} else if (value.length) {
setSelectedElementId(value[value.length - 1].id);
// handle when a user types OR pastes in the input
const handleInputChange = (inputValue: string) => {
setInputValue(inputValue);

delimiters.forEach((delimiter) => {
if (inputValue.includes(delimiter)) {
const newTagStrings = inputValue
.split(delimiter)
.map((tag) => tag.trim())
.filter(Boolean);

inputValue.trim().length > 1 && onChange([...fieldValue, ...newTagStrings]);
setInputValue("");
}
}
});
};

const handleInputBlur = () => {
if (addOnBlur) {
onAddValue();
// handle when user presses keyboard keys in the input
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
if (!inputValue || !inputValue.length) {
return;
}
switch (event.key) {
case "Enter":
case "Tab":
inputValue.trim().length > 1 && onChange([...fieldValue, inputValue.trim()]);

event.preventDefault();
setInputValue("");
}
};

const inputPlaceholder = !value.length && inputProps?.placeholder ? inputProps.placeholder : "";

return (
<MainContainer
onBlur={handleContainerBlur}
onClick={handleContainerClick}
className={className}
error={error}
disabled={disabled}
>
{value.map((item, key) => (
<TagItem
disabled={disabled}
key={`tag-${key}`}
item={item}
onDeleteTag={onDelete}
isSelected={item.id === selectedElementId}
/>
))}
<InputElement
{...inputProps}
<div data-testid="tag-input">
<CreatableSelect
inputId={id}
name={name}
disabled={disabled}
autoComplete="off"
placeholder={inputPlaceholder}
ref={inputElement}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
value={currentInputValue}
onChange={(event) => {
setSelectedElementId("");
setCurrentInputValue(event.target.value);
}}
components={components}
inputValue={inputValue}
isClearable
isMulti
onBlur={() => handleDelete}
menuIsOpen={false}
onChange={handleDelete}
onInputChange={handleInputChange}
onKeyDown={handleKeyDown}
value={tags}
isDisabled={disabled}
styles={customStyles}
/>
</MainContainer>
</div>
);
};
Loading

0 comments on commit d2d8989

Please sign in to comment.