diff --git a/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md b/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md
new file mode 100644
index 00000000000..0001d750737
--- /dev/null
+++ b/packages/manager/.changeset/pr-9963-tech-stories-1701881397641.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Tech Stories
+---
+
+TagsInput & TagsPanel Storybook v7 Stories ([#9963](https://github.com/linode/manager/pull/9963))
diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx
index 5e83954a674..43df4076fb8 100644
--- a/packages/manager/.storybook/preview.tsx
+++ b/packages/manager/.storybook/preview.tsx
@@ -89,7 +89,6 @@ const preview: Preview = {
-
>
),
},
diff --git a/packages/manager/src/components/Tag/Tag.stories.tsx b/packages/manager/src/components/Tag/Tag.stories.tsx
index 1a30037ecae..3cf20683e26 100644
--- a/packages/manager/src/components/Tag/Tag.stories.tsx
+++ b/packages/manager/src/components/Tag/Tag.stories.tsx
@@ -21,6 +21,6 @@ const meta: Meta = {
label: 'Tag',
},
component: Tag,
- title: 'Components/Chip/Tag',
+ title: 'Components/Tags/Tag',
};
export default meta;
diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.mdx b/packages/manager/src/components/TagsInput/TagsInput.stories.mdx
deleted file mode 100644
index 19957d07502..00000000000
--- a/packages/manager/src/components/TagsInput/TagsInput.stories.mdx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
-import { useArgs } from '@storybook/client-api';
-import { TagsInput } from './TagsInput';
-
-
-
-# Tags Input
-
-export const Template = (args) => {
- const [args2, updateArgs] = useArgs();
- const onChange = (updatedTags) => {
- updateArgs({ value: updatedTags });
- };
- return ;
-};
-
-
-
-
diff --git a/packages/manager/src/components/TagsInput/TagsInput.stories.tsx b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx
new file mode 100644
index 00000000000..77c1266f579
--- /dev/null
+++ b/packages/manager/src/components/TagsInput/TagsInput.stories.tsx
@@ -0,0 +1,50 @@
+import { useArgs } from '@storybook/client-api';
+import { Meta, StoryObj } from '@storybook/react';
+import React from 'react';
+
+import { Box } from 'src/components/Box';
+
+import { TagsInput } from './TagsInput';
+
+import type { TagsInputProps } from './TagsInput';
+import type { Item } from 'src/components/EnhancedSelect/Select';
+
+export const Default: StoryObj = {
+ args: {
+ disabled: false,
+ hideLabel: false,
+ label: '',
+ menuPlacement: 'bottom',
+ name: '',
+ tagError: '',
+ value: [
+ { label: 'tag-1', value: 'tag-1' },
+ { label: 'tag-2', value: 'tag-2' },
+ ],
+ },
+ render: (args) => {
+ const TagsInputWrapper = () => {
+ const [, setTags] = useArgs();
+ const handleUpdateTags = (selected: Item[]) => {
+ return setTags({ value: selected });
+ };
+
+ return (
+
+ handleUpdateTags(selected)}
+ />
+
+ );
+ };
+
+ return TagsInputWrapper();
+ },
+};
+
+const meta: Meta = {
+ component: TagsInput,
+ title: 'Components/Tags/Tags Input',
+};
+export default meta;
diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx
index 7745e332061..2d3cb5495b4 100644
--- a/packages/manager/src/components/TagsInput/TagsInput.tsx
+++ b/packages/manager/src/components/TagsInput/TagsInput.tsx
@@ -17,17 +17,43 @@ export interface Tag {
}
export interface TagsInputProps {
+ /**
+ * If true, the component is disabled.
+ *
+ * @default false
+ */
disabled?: boolean;
+ /**
+ * If true, the label is hidden, yet still accessible to screen readers.
+ */
hideLabel?: boolean;
+ /**
+ * The label for the input.
+ */
label?: string;
+ /**
+ * The placement of the menu, relative to the select input.
+ */
menuPlacement?: 'auto' | 'bottom' | 'top';
+ /**
+ * The name of the input.
+ */
name?: string;
+ /**
+ * Callback fired when the value changes.
+ */
onChange: (selected: Item[]) => void;
+ /**
+ * An error to display beneath the input.
+ */
tagError?: string;
+ /**
+ * The value of the input.
+ */
value: Item[];
}
-const TagsInput = (props: TagsInputProps) => {
+export const TagsInput = (props: TagsInputProps) => {
const {
disabled,
hideLabel,
@@ -115,5 +141,3 @@ const TagsInput = (props: TagsInputProps) => {
/>
);
};
-
-export { TagsInput };
diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx
deleted file mode 100644
index cc386cbf331..00000000000
--- a/packages/manager/src/components/TagsPanel/TagsPanel.stories.mdx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
-import { Typography } from 'src/components/Typography';
-import { TagsPanel } from './TagsPanel';
-import { useArgs } from '@storybook/client-api';
-
- (
-
-
-
- ),
- ]}
-/>
-
-# Tags Panel
-
-export const _tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
-
-export const Template = (args) => {
- const [localArgs, setLocalArgs] = useArgs();
- const updateTags = (selected) => {
- return Promise.resolve(setLocalArgs({ tags: selected }));
- };
- return (
- <>
-
- >
- );
-};
-
-
-
-
diff --git a/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx
new file mode 100644
index 00000000000..a7c8ce8b5cb
--- /dev/null
+++ b/packages/manager/src/components/TagsPanel/TagsPanel.stories.tsx
@@ -0,0 +1,40 @@
+import { useArgs } from '@storybook/client-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
new file mode 100644
index 00000000000..af1bd19d7eb
--- /dev/null
+++ b/packages/manager/src/components/TagsPanel/TagsPanel.styles.ts
@@ -0,0 +1,99 @@
+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%',
+ },
+ addTagButton: {
+ '& svg': {
+ color: theme.color.tagIcon,
+ height: 10,
+ marginLeft: 10,
+ width: 10,
+ },
+ alignItems: 'center',
+ backgroundColor: theme.color.tagButton,
+ border: 'none',
+ borderRadius: 3,
+ color: theme.textColors.linkActiveLight,
+ cursor: 'pointer',
+ display: 'flex',
+ fontFamily: theme.font.bold,
+ fontSize: '0.875rem',
+ justifyContent: 'center',
+ padding: '7px 10px',
+ whiteSpace: 'nowrap',
+ },
+ 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
new file mode 100644
index 00000000000..1c3f2b5b92c
--- /dev/null
+++ b/packages/manager/src/components/TagsPanel/TagsPanel.test.tsx
@@ -0,0 +1,128 @@
+import { fireEvent, waitFor } from '@testing-library/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from 'react-query';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { TagsPanel } from './TagsPanel';
+
+import type { TagsPanelProps } from './TagsPanel';
+
+const queryClient = new QueryClient();
+
+const renderWithQueryClient = (ui: React.ReactElement) => {
+ return renderWithTheme(
+ {ui}
+ );
+};
+
+describe('TagsPanel', () => {
+ it('renders TagsPanel component with existing tags', async () => {
+ const updateTagsMock = vi.fn(() => Promise.resolve());
+
+ const { getByLabelText, getByText } = renderWithQueryClient(
+
+ );
+
+ 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 } = renderWithQueryClient(
+
+ );
+
+ fireEvent.click(getByText('Add a tag'));
+
+ fireEvent.change(getByLabelText('Create or Select a Tag'), {
+ target: { value: 'NewTag' },
+ });
+
+ const newTagItem = getByText('Create "NewTag"');
+ fireEvent.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 } = renderWithQueryClient(
+
+ );
+
+ fireEvent.click(getByText('Add a tag'));
+
+ fireEvent.change(getByLabelText('Create or Select a Tag'), {
+ target: { value: 'yz' },
+ });
+
+ const newTagItem = getByText('Create "yz"');
+ fireEvent.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,
+ } = renderWithQueryClient(
+
+ );
+
+ 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 } = renderWithQueryClient(
+
+ );
+
+ 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
index 023b878a449..ba1fb36718d 100644
--- a/packages/manager/src/components/TagsPanel/TagsPanel.tsx
+++ b/packages/manager/src/components/TagsPanel/TagsPanel.tsx
@@ -1,7 +1,5 @@
-import { Theme } from '@mui/material/styles';
import * as React from 'react';
import { useQueryClient } from 'react-query';
-import { makeStyles } from 'tss-react/mui';
import Plus from 'src/assets/icons/plusSign.svg';
import { CircleProgress } from 'src/components/CircleProgress';
@@ -12,102 +10,7 @@ import { useProfile } from 'src/queries/profile';
import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
-const useStyles = makeStyles()((theme: Theme) => ({
- '@keyframes fadeIn': {
- from: {
- opacity: 0,
- },
- to: {
- opacity: 1,
- },
- },
- addButtonWrapper: {
- display: 'flex',
- justifyContent: 'flex-start',
- width: '100%',
- },
- addTagButton: {
- '& svg': {
- color: theme.color.tagIcon,
- height: 10,
- marginLeft: 10,
- width: 10,
- },
- alignItems: 'center',
- backgroundColor: theme.color.tagButton,
- border: 'none',
- borderRadius: 3,
- color: theme.textColors.linkActiveLight,
- cursor: 'pointer',
- display: 'flex',
- fontFamily: theme.font.bold,
- fontSize: '0.875rem',
- justifyContent: 'center',
- padding: '7px 10px',
- whiteSpace: 'nowrap',
- },
- 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',
- },
-}));
+import { useStyles } from './TagsPanel.styles';
interface Item {
label: string;
@@ -123,13 +26,21 @@ interface ActionMeta {
}
export interface TagsPanelProps {
- align?: 'left' | 'right';
+ /**
+ * 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;
}
-const TagsPanel = (props: TagsPanelProps) => {
+export const TagsPanel = (props: TagsPanelProps) => {
const { classes, cx } = useStyles();
const { disabled, tags, updateTags } = props;
@@ -307,5 +218,3 @@ const TagsPanel = (props: TagsPanelProps) => {
>
);
};
-
-export { TagsPanel };