diff --git a/packages/manager/.changeset/pr-10122-changed-1706567057589.md b/packages/manager/.changeset/pr-10122-changed-1706567057589.md new file mode 100644 index 00000000000..c59bf806b16 --- /dev/null +++ b/packages/manager/.changeset/pr-10122-changed-1706567057589.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve tags experience ([#10122](https://github.com/linode/manager/pull/10122)) diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 20fd80ba968..cc9a4100c73 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { Box } from 'src/components/Box'; import { TextField, TextFieldProps } from 'src/components/TextField'; +import { CircleProgress } from '../CircleProgress'; +import { InputAdornment } from '../InputAdornment'; import { CustomPopper, SelectedIcon, @@ -31,8 +33,8 @@ export interface EnhancedAutocompleteProps< label: string; /** Removes the top margin from the input label, if desired. */ noMarginTop?: boolean; - /** Text to show when the Autocomplete search yields no results. */ - noOptionsText?: string; + /** Element to show when the Autocomplete search yields no results. */ + noOptionsText?: JSX.Element | string; placeholder?: string; /** Label for the "select all" option. */ selectAllLabel?: string; @@ -115,10 +117,15 @@ export const Autocomplete = < ...params.InputProps, ...textFieldProps?.InputProps, endAdornment: ( - + <> + {loading && ( + + + + )} {textFieldProps?.InputProps?.endAdornment} {params.InputProps.endAdornment} - + ), }} /> diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index a35a6ef302a..df83fcc4c88 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -1,6 +1,7 @@ import { styled } from '@mui/material/styles'; import Plus from 'src/assets/icons/plusSign.svg'; +import { omittedProps } from 'src/utilities/omittedProps'; import { Button } from './Button'; @@ -12,9 +13,15 @@ import { Button } from './Button'; */ export const StyledTagButton = styled(Button, { label: 'StyledTagButton', -})(({ theme, ...props }) => ({ + shouldForwardProp: omittedProps(['panel']), +})<{ panel?: boolean }>(({ theme, ...props }) => ({ border: 'none', fontSize: '0.875rem', + minHeight: 30, + whiteSpace: 'nowrap', + ...(props.panel && { + height: 34, + }), ...(!props.disabled && { '&:hover, &:focus': { backgroundColor: theme.color.tagButton, diff --git a/packages/manager/src/components/TagCell.stories.tsx b/packages/manager/src/components/TagCell.stories.tsx new file mode 100644 index 00000000000..2e5fd4ffcd9 --- /dev/null +++ b/packages/manager/src/components/TagCell.stories.tsx @@ -0,0 +1,62 @@ +import { useArgs } from '@storybook/preview-api'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { Box } from 'src/components/Box'; + +import { TagCell, TagCellProps } from './TagCell/TagCell'; + +const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; + +export const Default: StoryObj = { + render: (args) => { + const TagsInputWrapper = () => { + const [{ tags }, updateArgs] = useArgs(); + const handleUpdateTags = (selected: string[]) => { + return Promise.resolve(updateArgs({ tags: selected })); + }; + + return ( + + + + ); + }; + + return TagsInputWrapper(); + }, +}; + +export const InlineView: StoryObj = { + render: (args) => { + const TagsInputWrapper = () => { + const [{ tags }, updateArgs] = useArgs(); + const handleUpdateTags = (selected: string[]) => { + return Promise.resolve(updateArgs({ tags: selected })); + }; + + return ( + + undefined} + tags={tags} + updateTags={handleUpdateTags} + /> + + ); + }; + + return TagsInputWrapper(); + }, +}; + +const meta: Meta = { + args: { + disabled: false, + tags: _tags, + }, + component: TagCell, + title: 'Components/Tags/Tag Cell', +}; +export default meta; diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index 2f5bb5d1b9b..a03008278e2 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -1,26 +1,21 @@ -import { styled } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { useProfile } from 'src/queries/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; -import { omittedProps } from 'src/utilities/omittedProps'; + +import { Autocomplete } from '../Autocomplete/Autocomplete'; interface AddTagProps { addTag: (tag: string) => Promise; - fixedMenu?: boolean; - inDetailsContext?: boolean; - label?: string; + existingTags: string[]; onClose?: () => void; - tags: string[]; } -const AddTag = (props: AddTagProps) => { - const { addTag, fixedMenu, label, onClose, tags } = props; +export const AddTag = (props: AddTagProps) => { + const { addTag, existingTags, onClose } = props; const queryClient = useQueryClient(); - const [isLoading, setIsLoading] = React.useState(false); const { data: profile } = useProfile(); const { data: accountTags, @@ -30,67 +25,68 @@ const AddTag = (props: AddTagProps) => { // thing we lose is preexisting tabs as options; the add tag flow // should still work. - const tagOptions = accountTags - ?.filter((tag) => !tags.includes(tag.label)) - .map((tag) => ({ label: tag.label, value: tag.label })); + const [inputValue, setInputValue] = React.useState(''); + const [loading, setLoading] = React.useState(false); - const handleAddTag = (newTag: Item) => { - if (newTag?.value) { - setIsLoading(true); - addTag(newTag.value) - .then(() => { - if (accountTags) { - updateTagsSuggestionsData([...accountTags, newTag], queryClient); - } - if (onClose) { - onClose(); - } - }) - .finally(() => setIsLoading(false)); - } - }; + const createTag = + !!accountTags && + !!inputValue && + !accountTags.some( + (tag) => tag.label.toLowerCase() == inputValue.toLowerCase() + ); + + const tagOptions: { displayLabel?: string; label: string }[] = [ + ...(createTag + ? [{ displayLabel: `Create "${inputValue}"`, label: inputValue }] + : []), + ...(accountTags?.filter((tag) => !existingTags.includes(tag.label)) ?? []), + ]; - const loading = accountTagsLoading || isLoading; + const handleAddTag = (newTag: string) => { + setLoading(true); + addTag(newTag) + .then(() => { + if (accountTags) { + updateTagsSuggestionsData( + [...accountTags, { label: newTag }], + queryClient + ); + } + }) + .finally(() => { + setLoading(false); + if (onClose) { + onClose(); + } + }); + }; return ( - { + if (onClose) { + onClose(); + } + }} + onChange={(_, value) => { + if (value) { + handleAddTag(typeof value == 'string' ? value : value.label); + } + }} + renderOption={(props, option) => ( +
  • {option.displayLabel ?? option.label}
  • + )} + disableClearable + forcePopupIcon + label={'Create or Select a Tag'} + loading={accountTagsLoading || loading} + noOptionsText={{`"${inputValue}" already added`}} // Will display create option unless that tag is already added + onInputChange={(_, value) => setInputValue(value)} + openOnFocus + options={tagOptions ?? []} placeholder="Create or Select a Tag" - small + sx={{ width: '100%' }} + textFieldProps={{ autoFocus: true, hideLabel: true }} /> ); }; - -export { AddTag }; - -const StyledSelect = styled(Select, { - shouldForwardProp: omittedProps(['fixedMenu', 'inDetailsContext']), -})<{ - fixedMenu?: boolean; - inDetailsContext?: boolean; -}>(({ ...props }) => ({ - padding: '0px', - width: '100%', - ...(props.fixedMenu && { - '& .react-select__menu': { - margin: '2px 0 0 0', - }, - }), - ...(props.inDetailsContext && { - display: 'flex', - flexBasis: '100%', - justifyContent: 'flex-end', - width: '415px', - }), -})); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index cdccac6561c..a79ea11bd84 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,27 +1,49 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz'; -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { SxProps } from '@mui/system'; import * as React from 'react'; -import { CircleProgress } from 'src/components/CircleProgress'; import { IconButton } from 'src/components/IconButton'; import { Tag } from 'src/components/Tag/Tag'; +import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { omittedProps } from 'src/utilities/omittedProps'; import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; +import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; -interface TagCellProps { +export interface TagCellProps { + /** + * Disable adding or deleting tags. + */ disabled?: boolean; - listAllTags: (tags: string[]) => void; + + /** + * An optional callback that is invoked when the tag list + * overflows and the user clicks to view all tags. + */ + listAllTags?: () => void; + + /** + * Additional styles to apply to the tag list. + */ sx?: SxProps; + + /** + * The list of tags to display. + */ tags: string[]; + + /** + * A callback that is invoked when the user updates + * the tag list (i.e., by adding or deleting a tag). + */ updateTags: (tags: string[]) => Promise; } // https://stackoverflow.com/questions/143815/determine-if-an-html-elements-content-overflows -const checkOverflow = (el: any) => { +const checkOverflow = (el: HTMLElement) => { const curOverflow = el.style.overflow; if (!curOverflow || curOverflow === 'visible') { @@ -35,103 +57,123 @@ const checkOverflow = (el: any) => { return isOverflowing; }; -const TagCell = (props: TagCellProps) => { - const { disabled, sx, tags, updateTags } = props; - - const [hasOverflow, setOverflow] = React.useState(false); - const [addingTag, setAddingTag] = React.useState(false); - const [loading, setLoading] = React.useState(false); - const overflowRef = React.useCallback( - (node: HTMLDivElement) => { - if (node !== null) { - setOverflow(checkOverflow(node)); - } - }, - // The function doesn't care about tags directly, - // but if the tags list changes we want to check to see if - // the overflow state has changed. - // eslint-disable-next-line - [tags] - ); +export const TagCell = (props: TagCellProps) => { + const { disabled, listAllTags, sx, tags } = props; - const handleAddTag = async (tag: string) => { - await updateTags([...tags, tag]); - }; + const [addingTag, setAddingTag] = React.useState(false); + const [loading, setLoading] = React.useState(false); + + const [elRef, setElRef] = React.useState(null); + + const windowDimensions = useWindowDimensions(); + + const [hasOverflow, setHasOverflow] = React.useState(false); + React.useLayoutEffect(() => { + setHasOverflow(!!elRef && checkOverflow(elRef)); + }, [windowDimensions, tags, elRef]); - const handleDeleteTag = (tagToDelete: string) => { + const handleUpdateTag = (updatedTags: string[]) => { setLoading(true); - updateTags(tags.filter((tag) => tag !== tagToDelete)).finally(() => - setLoading(false) - ); + return props.updateTags(updatedTags).finally(() => { + setLoading(false); + }); }; - return ( - ( + } + onClick={() => setAddingTag(true)} + panel={props.panel} + title="Add a tag" > - {loading ? ( - - - - ) : null} - {addingTag ? ( - setAddingTag(false)} - tags={tags} - /> - ) : ( - <> - + Add a tag + + ); + + return ( + <> + {(addingTag || panelView) && ( +
    + {panelView && !addingTag && } + {addingTag && ( + handleUpdateTag([...tags, tag])} + existingTags={tags} + onClose={() => setAddingTag(false)} + /> + )} +
    + )} + {(!addingTag || panelView) && ( + + + {loading ? ( + + + + ) : null} {tags.map((thisTag) => ( + handleUpdateTag(tags.filter((tag) => tag !== thisTag)) + } colorVariant="lightBlue" disabled={disabled} key={`tag-item-${thisTag}`} label={thisTag} loading={loading} - onDelete={() => handleDeleteTag(thisTag)} /> ))} - {hasOverflow ? ( + {hasOverflow && !panelView ? ( props.listAllTags(tags)} - onKeyPress={() => props.listAllTags(tags)} + onClick={() => listAllTags()} + onKeyPress={() => listAllTags()} size="large" > ) : null} - } - onClick={() => setAddingTag(true)} - title="Add a tag" - > - Add a tag - - + {!panelView && } + )} -
    + ); }; -export { TagCell }; - -const StyledGrid = styled(Grid)({ - justifyContent: 'flex-end', +const StyledGrid = styled(Grid)((props) => ({ + justifyContent: props.wrap == 'wrap' ? 'flex-start' : 'flex-end', minHeight: 40, position: 'relative', -}); +})); const StyledCircleDiv = styled('div')({ alignItems: 'center', @@ -144,15 +186,16 @@ const StyledCircleDiv = styled('div')({ }); const StyledTagListDiv = styled('div', { - shouldForwardProp: omittedProps(['hasOverflow']), + shouldForwardProp: omittedProps(['hasOverflow', 'wrap']), })<{ hasOverflow: boolean; + wrap: boolean; }>(({ ...props }) => ({ '& .MuiChip-root:last-child': { marginRight: 4, }, display: 'flex', - flexWrap: 'nowrap', + flexWrap: props.wrap ? 'wrap' : 'nowrap', overflow: 'hidden', position: 'relative', whiteSpace: 'nowrap', diff --git a/packages/manager/src/components/TagCell/TagDrawer.tsx b/packages/manager/src/components/TagCell/TagDrawer.tsx index 7a820fb74f5..71fd0a78bcb 100644 --- a/packages/manager/src/components/TagCell/TagDrawer.tsx +++ b/packages/manager/src/components/TagCell/TagDrawer.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import { Drawer } from 'src/components/Drawer'; -import { TagsPanel } from 'src/components/TagsPanel/TagsPanel'; + +import { TagCell } from './TagCell'; export type OpenTagDrawer = (id: number, label: string, tags: string[]) => void; @@ -14,14 +15,12 @@ export interface TagDrawerProps { updateTags: (tags: string[]) => Promise; } -const TagDrawer = (props: TagDrawerProps) => { +export const TagDrawer = (props: TagDrawerProps) => { const { disabled, entityLabel, onClose, open, tags, updateTags } = props; return ( - + ); }; - -export { TagDrawer }; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx deleted file mode 100644 index 258f068a5e3..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useArgs } from '@storybook/preview-api'; -import { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; - -import { Box } from 'src/components/Box'; - -import { TagsPanel } from './TagsPanel'; - -import type { TagsPanelProps } from './TagsPanel'; - -const _tags: string[] = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; - -export const Default: StoryObj = { - render: (args) => { - const TagsInputWrapper = () => { - const [{ tags }, updateArgs] = useArgs(); - const handleUpdateTags = (selected: string[]) => { - return Promise.resolve(updateArgs({ tags: selected })); - }; - - return ( - - - - ); - }; - - return TagsInputWrapper(); - }, -}; - -const meta: Meta = { - args: { - disabled: false, - tags: _tags, - }, - component: TagsPanel, - title: 'Components/Tags/Tags Panel', -}; -export default meta; diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts deleted file mode 100644 index 68509434b4b..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()((theme: Theme) => ({ - '@keyframes fadeIn': { - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, - }, - addButtonWrapper: { - display: 'flex', - justifyContent: 'flex-start', - width: '100%', - }, - errorNotice: { - '& .noticeText': { - fontFamily: '"LatoWeb", sans-serif', - }, - animation: '$fadeIn 225ms linear forwards', - borderLeft: `5px solid ${theme.palette.error.dark}`, - marginTop: 20, - paddingLeft: 10, - textAlign: 'left', - }, - hasError: { - marginTop: 0, - }, - loading: { - opacity: 0.4, - }, - progress: { - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - width: '100%', - zIndex: 2, - }, - selectTag: { - '& .error-for-scroll > div': { - flexDirection: 'row', - flexWrap: 'wrap-reverse', - }, - '& .input': { - '& p': { - borderLeft: 'none', - color: theme.color.grey1, - fontSize: '.9rem', - }, - }, - '& .react-select__input': { - backgroundColor: 'transparent', - color: theme.palette.text.primary, - fontSize: '.9rem', - }, - '& .react-select__value-container': { - padding: '6px', - }, - animation: '$fadeIn .3s ease-in-out forwards', - marginTop: -3.5, - minWidth: 275, - position: 'relative', - textAlign: 'left', - width: '100%', - zIndex: 3, - }, - tag: { - marginRight: 4, - marginTop: theme.spacing(0.5), - }, - tagsPanelItemWrapper: { - marginBottom: theme.spacing(), - position: 'relative', - }, -})); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx deleted file mode 100644 index 93ee05ff995..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { TagsPanel } from './TagsPanel'; - -const tagsPanelProps = { - entityId: 123, - tags: ['Tag1', 'Tag2'], -}; - -describe('TagsPanel', () => { - it('renders TagsPanel component with existing tags', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const addTagButton = getByText('Add a tag'); - expect(addTagButton).toBeInTheDocument(); - - fireEvent.click(addTagButton); - - const tagInput = getByLabelText('Create or Select a Tag'); - expect(tagInput).toBeInTheDocument(); - }); - - it('creates a new tag successfully', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - await userEvent.click(getByText('Add a tag')); - - fireEvent.change(getByLabelText('Create or Select a Tag'), { - target: { value: 'NewTag' }, - }); - - const newTagItem = getByText('Create "NewTag"'); - await userEvent.click(newTagItem); - - await waitFor(() => { - expect(updateTagsMock).toHaveBeenCalledWith(['NewTag', 'Tag1', 'Tag2']); - }); - }); - - it('displays an error message for invalid tag creation', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText } = renderWithTheme( - - ); - - await userEvent.click(getByText('Add a tag')); - - fireEvent.change(getByLabelText('Create or Select a Tag'), { - target: { value: 'yz' }, - }); - - const newTagItem = getByText('Create "yz"'); - - await userEvent.click(newTagItem); - - await waitFor(() => - expect( - getByText('Tag "yz" length must be 3-50 characters') - ).toBeInTheDocument() - ); - }); - - it('deletes a tag successfully', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByLabelText, getByText, queryByLabelText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const deleteTagButton = getByLabelText("Delete Tag 'Tag1'"); - fireEvent.click(deleteTagButton); - - await waitFor(() => expect(updateTagsMock).toHaveBeenCalledWith(['Tag2'])); - - expect(queryByLabelText("Search for Tag 'tag2'")).toBeNull(); - }); - - it('prevents creation or deletion of tags when disabled', async () => { - const updateTagsMock = vi.fn(() => Promise.resolve()); - - const { getByText, queryByLabelText, queryByText } = renderWithTheme( - - ); - - expect(getByText('Tag1')).toBeInTheDocument(); - expect(getByText('Tag2')).toBeInTheDocument(); - - const addTagButton = getByText('Add a tag'); - expect(addTagButton).toBeInTheDocument(); - - fireEvent.click(addTagButton); - - const tagInput = queryByText('Create or Select a Tag'); - expect(tagInput).toBeNull(); - - const deleteTagButton = queryByLabelText("Delete Tag 'Tag1'"); - expect(deleteTagButton).toBeNull(); - - await waitFor(() => expect(updateTagsMock).not.toHaveBeenCalled()); - }); -}); diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.tsx deleted file mode 100644 index e3d262c82dc..00000000000 --- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import * as React from 'react'; - -import { - StyledPlusIcon, - StyledTagButton, -} from 'src/components/Button/StyledTagButton'; -import { CircleProgress } from 'src/components/CircleProgress'; -import Select from 'src/components/EnhancedSelect/Select'; -import { Tag } from 'src/components/Tag/Tag'; -import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; -import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; -import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; - -import { useStyles } from './TagsPanel.styles'; - -interface Item { - label: string; - value: string; -} - -interface Tag { - label: string; -} - -interface ActionMeta { - action: string; -} - -export interface TagsPanelProps { - /** - * If true, the input will be disabled and no tags can be added or removed. - */ - disabled?: boolean; - /** - * The tags to display. - */ - tags: string[]; - /** - * Callback fired when the tags are updated. - */ - updateTags: (tags: string[]) => Promise; -} - -export const TagsPanel = (props: TagsPanelProps) => { - const { classes, cx } = useStyles(); - const { disabled, tags, updateTags } = props; - - const queryClient = useQueryClient(); - - const [tagError, setTagError] = React.useState(''); - const [isCreatingTag, setIsCreatingTag] = React.useState(false); - const [tagsLoading, setTagsLoading] = React.useState(false); - - const { data: profile } = useProfile(); - - const { - data: userTags, - error: userTagsError, - isFetching: userTagsLoading, - } = useTagSuggestions(!profile?.restricted); - - const tagsToSuggest = React.useMemo( - () => - userTags - ?.filter((tag) => !tags.some((appliedTag) => appliedTag === tag.label)) - .map((tag) => ({ - label: tag.label, - value: tag.label, - })), - [userTags, tags] - ); - - React.useEffect(() => { - setTagError(''); - }, [isCreatingTag]); - - const toggleTagInput = () => { - if (!disabled) { - setIsCreatingTag((prev) => !prev); - } - }; - - const userTagsErrorDisplay = userTagsError - ? 'There was an error retrieving your tags.' - : ''; - - const handleDeleteTag = (label: string) => { - setTagsLoading(true); - - const tagsWithoutDeletedTag = tags.filter( - (thisTag: string) => thisTag !== label - ); - - updateTags(tagsWithoutDeletedTag) - .then(() => { - setTagError(''); - }) - .catch((e) => { - const tagError = getErrorStringOrDefault(e, 'Error while deleting tag'); - setTagError(tagError); - }) - .finally(() => { - setTagsLoading(false); - }); - }; - - const handleCreateTag = (value: Item, actionMeta: ActionMeta) => { - const inputValue = value && value.value; - - /* - * This comes from the react-select API - * basically, we only want to make a request if the user is either - * hitting the enter button or choosing a selection from the dropdown - */ - if ( - actionMeta.action !== 'select-option' && - actionMeta.action !== 'create-option' - ) { - return; - } - - const tagExists = (tag: string) => { - return tags.some((el) => { - return el === tag; - }); - }; - - toggleTagInput(); - - if (inputValue.length < 3 || inputValue.length > 50) { - setTagError(`Tag "${inputValue}" length must be 3-50 characters`); - } else if (tagExists(inputValue)) { - setTagError(`Tag "${inputValue}" is a duplicate`); - } else { - setTagError(''); - setTagsLoading(true); - updateTags([...tags, value.label].sort()) - .then(() => { - if (userTags) { - updateTagsSuggestionsData([...userTags, value], queryClient); - } - }) - .catch((e) => { - const tagError = getErrorStringOrDefault( - e, - 'Error while creating tag' - ); - setTagError(tagError); - }) - .finally(() => { - setTagsLoading(false); - }); - } - }; - - return ( - <> - {isCreatingTag ? ( -