-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
1d2a4ba
commit d2d8989
Showing
6 changed files
with
235 additions
and
234 deletions.
There are no files selected for viewing
9 changes: 9 additions & 0 deletions
9
airbyte-webapp/src/components/ui/TagInput/TagInput.module.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
95
airbyte-webapp/src/components/ui/TagInput/TagInput.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
265
airbyte-webapp/src/components/ui/TagInput/TagInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.