Skip to content

Commit

Permalink
change: [M3-7540] - Improve tags experience (linode#10122)
Browse files Browse the repository at this point in the history
* Trying some stuff...

* Introduce new MutationObserver hook

* Use new MutationObserver hook to listen for parent becoming visible

* Clean up

* Improve TagCell styles for small displays

* Added changeset: Tag overflow detection

* Improve tags experience and replace deprecated Select with Autocomplete component

* Update changeset

* Remove workaround and update changeset

* Fix test failure

* Fix race condition with new useAtomic hook

* Delete TagsPanel in favor of TagCell

* Add test for useAtomic

* Fix inconsistent TagCell margin bottom

* Feedback @abailly-akamai

* Add debouncing to useAtomic

* Remove useAtomic

* Fix errors due to forwarded prop

* Add loading state to AddTag

* Add storybook entry for TagCell

* Feedback @abailly-akamai
  • Loading branch information
hkhalil-akamai authored Apr 3, 2024
1 parent fa7d1bb commit 0016832
Show file tree
Hide file tree
Showing 21 changed files with 304 additions and 724 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10122-changed-1706567057589.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Improve tags experience ([#10122](https://github.com/linode/manager/pull/10122))
15 changes: 11 additions & 4 deletions packages/manager/src/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -115,10 +117,15 @@ export const Autocomplete = <
...params.InputProps,
...textFieldProps?.InputProps,
endAdornment: (
<React.Fragment>
<>
{loading && (
<InputAdornment position="end">
<CircleProgress mini={true} />
</InputAdornment>
)}
{textFieldProps?.InputProps?.endAdornment}
{params.InputProps.endAdornment}
</React.Fragment>
</>
),
}}
/>
Expand Down
9 changes: 8 additions & 1 deletion packages/manager/src/components/Button/StyledTagButton.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions packages/manager/src/components/TagCell.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<TagCellProps> = {
render: (args) => {
const TagsInputWrapper = () => {
const [{ tags }, updateArgs] = useArgs();
const handleUpdateTags = (selected: string[]) => {
return Promise.resolve(updateArgs({ tags: selected }));
};

return (
<Box sx={{ height: 300 }}>
<TagCell {...args} tags={tags} updateTags={handleUpdateTags} />
</Box>
);
};

return TagsInputWrapper();
},
};

export const InlineView: StoryObj<TagCellProps> = {
render: (args) => {
const TagsInputWrapper = () => {
const [{ tags }, updateArgs] = useArgs();
const handleUpdateTags = (selected: string[]) => {
return Promise.resolve(updateArgs({ tags: selected }));
};

return (
<Box sx={{ height: 300 }}>
<TagCell
{...args}
listAllTags={() => undefined}
tags={tags}
updateTags={handleUpdateTags}
/>
</Box>
);
};

return TagsInputWrapper();
},
};

const meta: Meta<TagCellProps> = {
args: {
disabled: false,
tags: _tags,
},
component: TagCell,
title: 'Components/Tags/Tag Cell',
};
export default meta;
128 changes: 62 additions & 66 deletions packages/manager/src/components/TagCell/AddTag.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
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,
Expand All @@ -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<string>) => {
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 (
<StyledSelect
// eslint-disable-next-line
autoFocus
creatable
createOptionPosition="first"
escapeClearsValue
hideLabel={!label}
isLoading={loading}
label={label ?? 'Add a tag'}
menuPosition={fixedMenu ? 'fixed' : 'absolute'}
onBlur={onClose}
onChange={handleAddTag}
options={tagOptions}
<Autocomplete
onBlur={() => {
if (onClose) {
onClose();
}
}}
onChange={(_, value) => {
if (value) {
handleAddTag(typeof value == 'string' ? value : value.label);
}
}}
renderOption={(props, option) => (
<li {...props}>{option.displayLabel ?? option.label}</li>
)}
disableClearable
forcePopupIcon
label={'Create or Select a Tag'}
loading={accountTagsLoading || loading}
noOptionsText={<i>{`"${inputValue}" already added`}</i>} // 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',
}),
}));
Loading

0 comments on commit 0016832

Please sign in to comment.