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 ; -}; - - - - {Template.bind({})} - - - - 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 ( - <> - - - ); -}; - - - - {Template.bind({})} - - - - 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 };