diff --git a/packages/manager/.changeset/pr-9497-tech-stories-1691208190450.md b/packages/manager/.changeset/pr-9497-tech-stories-1691208190450.md new file mode 100644 index 00000000000..6279d413b51 --- /dev/null +++ b/packages/manager/.changeset/pr-9497-tech-stories-1691208190450.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add Autocomplete Component ([#9497](https://github.com/linode/manager/pull/9497)) diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 00000000000..1acfc6b20f5 --- /dev/null +++ b/packages/manager/src/components/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,358 @@ +import { Linode } from '@linode/api-v4'; +import { Region } from '@linode/api-v4/lib/regions'; +import Close from '@mui/icons-material/Close'; +import { Box, Stack } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { action } from '@storybook/addon-actions'; +import React, { useState } from 'react'; + +import { Country } from 'src/components/EnhancedSelect/variants/RegionSelect/utils'; +import { Flag } from 'src/components/Flag'; +import { IconButton } from 'src/components/IconButton'; +import { List } from 'src/components/List'; +import { ListItem } from 'src/components/ListItem'; +import { linodeFactory } from 'src/factories'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; + +import { Autocomplete } from './Autocomplete'; +import { SelectedIcon } from './Autocomplete.styles'; + +import type { EnhancedAutocompleteProps } from './Autocomplete'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +const LABEL = 'Select a Linode'; + +interface OptionType { + data?: any; + label: string; + value: string; +} + +const linodes: OptionType[] = [ + { + label: 'Linode-001', + value: 'linode-001', + }, + { + label: 'Linode-002', + value: 'linode-002', + }, + { + label: 'Linode-003', + value: 'linode-003', + }, + { + label: 'Linode-004', + value: 'linode-004', + }, + { + label: 'Linode-005', + value: 'linode-005', + }, +]; + +const fakeRegionsData = [ + { + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + }, + { + country: 'us', + id: 'us-central', + label: 'Texas, TX', + }, + { + country: 'fr', + id: 'fr-par', + label: 'Paris, FR', + }, + { + country: 'br', + id: 'br-sao', + label: 'Sao Paulo, BR', + }, + { + country: 'jp', + id: 'jp-tyo', + label: 'Tokyo, JP', + }, +]; + +const getRegionsOptions = ( + fakeRegionsData: Pick[] +) => { + return fakeRegionsData.map((region: Region) => { + const group = getRegionCountryGroup(region); + return { + data: { + country: region.country, + flag: } />, + region: group, + }, + label: `${region.label} (${region.id})`, + value: region.id, + }; + }); +}; + +const AutocompleteWithSeparateSelectedOptions = ( + props: EnhancedAutocompleteProps +) => { + const [selectedOptions, setSelectedOptions] = React.useState( + [] + ); + + const handleSelectedOptions = React.useCallback((selected: OptionType[]) => { + setSelectedOptions(selected); + }, []); + + // Function to remove an option from the list of selected options + const removeOption = (optionToRemove: OptionType) => { + const updatedSelectedOptions = selectedOptions.filter( + (option) => option.value !== optionToRemove.value + ); + + // Call onSelectionChange to update the selected options + handleSelectedOptions(updatedSelectedOptions); + }; + + return ( + + setSelectedOptions(selected)} + renderTags={() => null} + value={selectedOptions} + /> + {selectedOptions.length > 0 && ( + <> + {`Linodes to be Unassigned from Subnet (${selectedOptions.length})`} + + + {selectedOptions.map((option) => ( + + {option.label} + removeOption(option)} + size="medium" + > + + + + ))} + + + )} + + ); +}; + +// Story Config ======================================================== + +const meta: Meta> = { + argTypes: { + onChange: { + action: 'onChange', + }, + }, + args: { + label: LABEL, + onChange: action('onChange'), + options: linodes, + }, + component: Autocomplete, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/Autocomplete', +}; + +export default meta; + +type Story = StoryObj; + +// Styled Components ================================================= + +const CustomValue = styled('span')(({ theme }) => ({ + fontFamily: theme.font.bold, + fontSize: '1rem', + wordBreak: 'break-word', +})); + +const CustomDescription = styled('span')(() => ({ + fontSize: '0.875rem', +})); + +const StyledListItem = styled('li')(() => ({ + alignItems: 'center', + display: 'flex', + width: '100%', +})); + +const StyledLabel = styled('span')(({ theme }) => ({ + color: theme.color.label, + fontFamily: theme.font.semiBold, + fontSize: '14px', +})); + +const StyledFlag = styled('span')(({ theme }) => ({ + marginRight: theme.spacing(1), +})); + +const SelectedOptionsHeader = styled('h4')(({ theme }) => ({ + color: theme.color.headline, + fontFamily: theme.font.bold, + fontSize: '14px', + textTransform: 'initial', +})); + +const SelectedOptionsList = styled(List)(({ theme }) => ({ + background: theme.bg.main, + maxWidth: '416px', + padding: '5px 0', + width: '100%', +})); + +const SelectedOptionsListItem = styled(ListItem)(() => ({ + justifyContent: 'space-between', + paddingBottom: 0, + paddingTop: 0, +})); + +const GroupHeader = styled('div')(({ theme }) => ({ + color: theme.color.headline, + fontFamily: theme.font.bold, + fontSize: '1rem', + padding: '15px 4px 4px 10px', + textTransform: 'initial', +})); + +const GroupItems = styled('ul')({ + padding: 0, +}); + +// Story Definitions ========================================================== + +export const Default: Story = { + args: { + defaultValue: linodes[0], + }, + render: (args) => , +}; + +export const NoOptionsMessage: Story = { + args: { + noOptionsText: + 'This is a custom message when there are no options to display.', + options: [], + }, + render: (args) => , +}; + +type RegionStory = StoryObj>; + +export const Regions: RegionStory = { + args: { + groupBy: (option) => option.data.region, + label: 'Select a Region', + options: getRegionsOptions(fakeRegionsData), + placeholder: 'Select a Region', + renderGroup: (params) => ( +
  • + {params.group} + {params.children} +
  • + ), + renderOption: (props, option, { selected }) => { + return ( + + + {option.data.flag} + {option.label} + + + + ); + }, + }, + render: (args) => , +}; + +export const CustomRenderOptions: RegionStory = { + args: { + label: 'Select a Linode to Clone', + options: [ + { + label: 'Nanode 1 GB, Debian 11, Newark, NJ', + value: 'debian-us-east', + }, + { + label: 'Nanode 2 GB, Debian 11, Newark, NJ', + value: 'debian-us-east-001', + }, + { + label: 'Nanode 3 GB, Debian 11, Newark, NJ', + value: 'debian-us-east-002', + }, + ], + placeholder: 'Select a Linode to Clone', + renderOption: (props, option, { selected }) => ( + + + {option.value} + {option.label} + + + + ), + }, + render: (args) => , +}; + +type MultiSelectStory = StoryObj>; + +const linodeList = linodeFactory.buildList(10); + +export const MultiSelect: MultiSelectStory = { + args: {}, + render: () => { + const Example = () => { + const [selectedLinodes, setSelectedLinodes] = useState([]); + return ( + setSelectedLinodes(value)} + options={linodeList} + value={selectedLinodes} + /> + ); + }; + + return ; + }, +}; + +type MultiSelectWithSeparateSelectionOptionsStory = StoryObj< + EnhancedAutocompleteProps +>; + +export const MultiSelectWithSeparateSelectionOptions: MultiSelectWithSeparateSelectionOptionsStory = { + args: { + multiple: true, + onChange: (e, selected: OptionType[]) => { + action('onChange')(selected.map((options) => options.value)); + }, + placeholder: LABEL, + selectAllLabel: 'Linodes', + }, + render: (args) => , +}; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx new file mode 100644 index 00000000000..45021702d47 --- /dev/null +++ b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx @@ -0,0 +1,65 @@ +import DoneIcon from '@mui/icons-material/Done'; +import Popper, { PopperProps } from '@mui/material/Popper'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { isPropValid } from 'src/utilities/isPropValid'; + +export const StyledListItem = styled('li', { + label: 'StyledListItem', + shouldForwardProp: (prop) => isPropValid(['selectAllOption'], prop), +})(({ theme }) => ({ + '&.MuiAutocomplete-option': { + overflow: 'unset', + }, + + '&:after': { + background: theme.color.border3, + bottom: '-5px', + content: '""', + height: '1px', + left: '-4px', + position: 'absolute', + width: '102%', + }, + + color: theme.color.headline, + fontFamily: theme.font.bold, + fontSize: '1rem', + marginBottom: '9px', + position: 'relative', +})); + +export const SelectedIcon = styled(DoneIcon, { + label: 'SelectedIcon', + shouldForwardProp: (prop) => prop != 'visible', +})<{ visible: boolean }>(({ visible }) => ({ + height: 17, + marginLeft: '-2px', + marginRight: '5px', + visibility: visible ? 'visible' : 'hidden', + width: 17, +})); + +export const CustomPopper = (props: PopperProps) => { + const { style, ...rest } = props; + + const updatedStyle = { + ...style, + width: style?.width + ? typeof style.width === 'string' + ? `calc(${style.width} + 2px)` + : style.width + 2 + : undefined, + zIndex: 1, + }; + + return ( + + ); +}; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.test.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.test.tsx new file mode 100644 index 00000000000..b8659acb52b --- /dev/null +++ b/packages/manager/src/components/Autocomplete/Autocomplete.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { Autocomplete } from './Autocomplete'; + +// Mock the options for testing +const options = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, +]; + +// Mock the selection change callback function for testing +const handleSelectionChange = jest.fn(); + +describe('Autocomplete Component', () => { + it('renders with the correct label', () => { + renderWithTheme( + + ); + + const labelElement = screen.getByLabelText('Test Label'); + expect(labelElement).toBeInTheDocument(); + }); + + it('calls the onSelectionChange callback when an option is selected', () => { + renderWithTheme( + + ); + + const inputElement = screen.getByRole('combobox'); + fireEvent.focus(inputElement); + fireEvent.change(inputElement, { target: { value: 'Option 1' } }); + + const optionElement = screen.getByText('Option 1'); + fireEvent.click(optionElement); + + const selectOption = handleSelectionChange.mock.calls[0][1]; + + expect(selectOption).toEqual(options[0]); + }); + + it('displays the error message when errorText prop is provided', () => { + const errorMessage = 'This field is required'; + + renderWithTheme( + + ); + + const errorElement = screen.getByText(errorMessage); + expect(errorElement).toBeInTheDocument(); + }); + + it('does not display the error message when errorText prop is not provided', () => { + renderWithTheme( + + ); + + const errorElement = screen.queryByText('This field is required'); + expect(errorElement).not.toBeInTheDocument(); + }); + + describe('renders all no options messages', () => { + it('displays the loading message when loading prop is true', () => { + renderWithTheme( + + ); + + const inputElement = screen.getByRole('combobox'); + fireEvent.focus(inputElement); + fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); + + const loadingMessage = screen.getByText('Loading...'); + expect(loadingMessage).toBeInTheDocument(); + }); + + it('displays the no options message when options are empty', () => { + renderWithTheme( + + ); + + const inputElement = screen.getByRole('combobox'); + fireEvent.focus(inputElement); + fireEvent.keyDown(inputElement, { key: 'ArrowDown' }); + + const noOptionsMessage = screen.getByText( + 'You have no options to choose from' + ); + expect(noOptionsMessage).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 00000000000..dfe47f46c58 --- /dev/null +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,164 @@ +import CloseIcon from '@mui/icons-material/Close'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import MuiAutocomplete from '@mui/material/Autocomplete'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { TextField } from 'src/components/TextField'; + +import { + CustomPopper, + SelectedIcon, + StyledListItem, +} from './Autocomplete.styles'; + +import type { AutocompleteProps } from '@mui/material/Autocomplete'; + +export interface EnhancedAutocompleteProps< + T extends { label: string }, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined +> extends Omit< + AutocompleteProps, + 'renderInput' + > { + /** Provides a hint with error styling to assist users. */ + errorText?: string; + /** Provides a hint with normal styling to assist users. */ + helperText?: string; + /** A required label for the Autocomplete to ensure accessibility. */ + label: string; + /** Removes the top margin from the input label, if desired. */ + noMarginTop?: boolean; + /** Label for the "select all" option. */ + selectAllLabel?: string; +} + +/** + * An Autocomplete component that provides a user-friendly select input + * allowing selection between options. + * + * @example + * console.log(selected)} + * options={[ + * { + * label: 'Apple', + * value: 'apple', + * } + * ]} + * /> + */ +export const Autocomplete = < + T extends { label: string }, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined +>( + props: EnhancedAutocompleteProps +) => { + const { + clearOnBlur = false, + defaultValue, + disablePortal = true, + errorText = '', + helperText, + label, + limitTags = 2, + loading = false, + loadingText, + multiple, + noMarginTop, + noOptionsText, + onBlur, + onChange, + options, + placeholder, + renderOption, + selectAllLabel = '', + value, + ...rest + } = props; + + const isSelectAllActive = + multiple && Array.isArray(value) && value.length === options.length; + + const selectAllText = isSelectAllActive ? 'Deselect All' : 'Select All'; + + const selectAllOption = { label: `${selectAllText} ${selectAllLabel}` }; + + const optionsWithSelectAll = [selectAllOption, ...options] as T[]; + + return ( + ( + + )} + renderOption={(props, option, state, ownerState) => { + const isSelectAllOption = option === selectAllOption; + const ListItem = isSelectAllOption ? StyledListItem : 'li'; + + return renderOption ? ( + renderOption(props, option, state, ownerState) + ) : ( + + <> + + {option.label} + + + + + ); + }} + ChipProps={{ deleteIcon: }} + PopperComponent={CustomPopper} + clearOnBlur={clearOnBlur} + defaultValue={defaultValue} + disableCloseOnSelect={multiple} + disablePortal={disablePortal} + limitTags={limitTags} + loading={loading} + loadingText={loadingText || 'Loading...'} + multiple={multiple} + noOptionsText={noOptionsText || You have no options to choose from} + onBlur={onBlur} + options={multiple ? optionsWithSelectAll : options} + popupIcon={} + value={value} + {...rest} + onChange={(e, value, reason, details) => { + if (onChange) { + if (details?.option === selectAllOption) { + if (isSelectAllActive) { + if (typeof value === typeof []) { + onChange(e, ([] as T[]) as typeof value, reason, details); + } + } else { + if (typeof value === typeof options) { + onChange(e, options as typeof value, reason, details); + } + } + } else { + onChange(e, value, reason, details); + } + } + }} + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.styles.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.styles.tsx deleted file mode 100644 index 25b9af80d8f..00000000000 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.styles.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import DoneIcon from '@mui/icons-material/Done'; -import { styled } from '@mui/material'; -import Popper, { PopperProps } from '@mui/material/Popper'; -import React from 'react'; - -export const SelectedIcon = styled(DoneIcon, { - shouldForwardProp: (prop) => prop != 'visible', -})<{ visible: boolean }>(({ visible }) => ({ - height: 17, - marginLeft: '-2px', - marginRight: '5px', - visibility: visible ? 'visible' : 'hidden', - width: 17, -})); - -export const CustomPopper = (props: PopperProps) => { - return ( - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 14cd3e610e4..3dff27db52d 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -5,13 +5,15 @@ import { Autocomplete } from '@mui/material'; import { SxProps } from '@mui/system'; import React from 'react'; +import { + CustomPopper, + SelectedIcon, +} from 'src/components/Autocomplete/Autocomplete.styles'; import { Box } from 'src/components/Box'; import { TextField } from 'src/components/TextField'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { mapIdsToLinodes } from 'src/utilities/mapIdsToLinodes'; -import { CustomPopper, SelectedIcon } from './LinodeSelect.styles'; - interface LinodeSelectProps { /** Whether to display the clear icon. Defaults to `true`. */ clearable?: boolean; diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx index f17f6cbd40b..1c971babe18 100644 --- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx @@ -2,8 +2,8 @@ import { Linode } from '@linode/api-v4/lib/linodes'; import { Box } from '@mui/material'; import * as React from 'react'; +import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { SelectedIcon } from 'src/features/Linodes/LinodeSelect/LinodeSelect.styles'; import { privateIPRegex } from 'src/utilities/ipUtils'; import type { TextFieldProps } from 'src/components/TextField'; diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 214537a71c9..e466a652a77 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -316,6 +316,10 @@ export const lightTheme: ThemeOptions = { }, }, tag: { + '&:not(.MuiChip-root)': { + borderRadius: '4px', + padding: '4px', + }, '.MuiChip-deleteIcon': { ':hover': { backgroundColor: primaryColors.main, @@ -326,6 +330,7 @@ export const lightTheme: ThemeOptions = { fontSize: '16px', margin: '0 4px', }, + backgroundColor: bg.lightBlue1, padding: '12px 2px', }, @@ -662,7 +667,6 @@ export const lightTheme: ThemeOptions = { backgroundColor: 'transparent', color: primaryColors.main, }, - padding: 12, }, }, }, @@ -891,8 +895,7 @@ export const lightTheme: ThemeOptions = { paddingBottom: 8, paddingTop: 8, textOverflow: 'initial', - transition: `${'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), '} - ${'color .2s cubic-bezier(0.4, 0, 0.2, 1)'}`, + transition: `${'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1)'}`, whiteSpace: 'initial', }, selected: {},