diff --git a/packages/manager/.changeset/pr-10780-tech-stories-1724255560445.md b/packages/manager/.changeset/pr-10780-tech-stories-1724255560445.md new file mode 100644 index 00000000000..e4311cecf31 --- /dev/null +++ b/packages/manager/.changeset/pr-10780-tech-stories-1724255560445.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace 'react-select' with Autocomplete in Profile ([#10780](https://github.com/linode/manager/pull/10780)) diff --git a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts index aab84a220d3..bc280f36e5a 100644 --- a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts @@ -93,14 +93,14 @@ const setSecurityQuestionAnswer = ( answer: string ) => { getSecurityQuestion(questionNumber).within(() => { - cy.get('[data-qa-enhanced-select]') + cy.findByLabelText(`Question ${questionNumber}`) .should('be.visible') .click() .type(`${question}{enter}`); }); getSecurityQuestionAnswer(questionNumber).within(() => { - cy.get('[data-testid="textfield-input"]') + cy.findByLabelText(`Answer ${questionNumber}`) .should('be.visible') .should('be.enabled') .click() diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 9172bf218c4..faf51f6d9c5 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -141,7 +141,11 @@ const Kubernetes = React.lazy(() => })) ); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); -const Profile = React.lazy(() => import('src/features/Profile/Profile')); +const Profile = React.lazy(() => + import('src/features/Profile/Profile').then((module) => ({ + default: module.Profile, + })) +); const NodeBalancers = React.lazy( () => import('src/features/NodeBalancers/NodeBalancers') ); diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx index d87e298ea51..9f31b39af99 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.styles.tsx @@ -1,10 +1,12 @@ import DoneIcon from '@mui/icons-material/Done'; -import Popper, { PopperProps } from '@mui/material/Popper'; +import Popper from '@mui/material/Popper'; import { styled } from '@mui/material/styles'; import React from 'react'; import { omittedProps } from 'src/utilities/omittedProps'; +import type { PopperProps } from '@mui/material/Popper'; + export const StyledListItem = styled('li', { label: 'StyledListItem', shouldForwardProp: omittedProps(['selectAllOption']), diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index de989e277c0..e6fbc02dd21 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -15,6 +15,7 @@ import { } from './Autocomplete.styles'; import type { AutocompleteProps } from '@mui/material/Autocomplete'; +import type { SxProps } from '@mui/system'; import type { TextFieldProps } from 'src/components/TextField'; export interface EnhancedAutocompleteProps< @@ -26,7 +27,7 @@ export interface EnhancedAutocompleteProps< AutocompleteProps, 'renderInput' > { - /** Removes "select all" option for multiselect */ + /** Removes "select all" option for mutliselect */ disableSelectAll?: boolean; /** Provides a hint with error styling to assist users. */ errorText?: string; @@ -41,6 +42,10 @@ export interface EnhancedAutocompleteProps< placeholder?: string; /** Label for the "select all" option. */ selectAllLabel?: string; + /** + * The prop that allows defining CSS style overrides for the PopperComponent. + */ + sxPopperComponent?: SxProps; textFieldProps?: Partial; } @@ -88,6 +93,7 @@ export const Autocomplete = < placeholder, renderOption, selectAllLabel = '', + sxPopperComponent, textFieldProps, value, ...rest @@ -104,6 +110,9 @@ export const Autocomplete = < return ( { + return ; + }} options={ multiple && !disableSelectAll && options.length > 0 ? optionsWithSelectAll @@ -163,7 +172,6 @@ export const Autocomplete = < ); }} ChipProps={{ deleteIcon: }} - PopperComponent={CustomPopper} clearOnBlur={clearOnBlur} data-qa-autocomplete defaultValue={defaultValue} diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 124ffd92732..96de8bb2306 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -32,16 +32,16 @@ const props = { describe('Create API Token Drawer', () => { it('checks API Token Drawer rendering', () => { - const { getByTestId, getByText } = renderWithTheme( + const { getAllByTestId, getByTestId, getByText } = renderWithTheme( ); const drawerTitle = getByText('Add Personal Access Token'); expect(drawerTitle).toBeVisible(); const labelTitle = getByText(/Label/); - const labelField = getByTestId('textfield-input'); + const labelField = getAllByTestId('textfield-input'); expect(labelTitle).toBeVisible(); - expect(labelField).toBeEnabled(); + expect(labelField[0]).toBeEnabled(); const expiry = getByText(/Expiry/); expect(expiry).toBeVisible(); @@ -67,12 +67,12 @@ describe('Create API Token Drawer', () => { }) ); - const { getByLabelText, getByTestId, getByText } = renderWithTheme( + const { getAllByTestId, getByLabelText, getByText } = renderWithTheme( ); - const labelField = getByTestId('textfield-input'); - await userEvent.type(labelField, 'my-test-token'); + const labelField = getAllByTestId('textfield-input'); + await userEvent.type(labelField[0], 'my-test-token'); const selectAllNoAccessPermRadioButton = getByLabelText( 'Select no access for all' @@ -110,8 +110,10 @@ describe('Create API Token Drawer', () => { }); it('Should default to 6 months for expiration', () => { - const { getByText } = renderWithTheme(); - getByText('In 6 months'); + const { getAllByRole } = renderWithTheme( + + ); + expect(getAllByRole('combobox')[0]).toHaveDisplayValue('In 6 months'); }); it('Should show the Child Account Access scope for a parent user account with the parent/child feature flag on', () => { diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index bf6eab61363..b5df3b5d57c 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -3,8 +3,8 @@ import { DateTime } from 'luxon'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { FormControl } from 'src/components/FormControl'; import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; @@ -30,7 +30,6 @@ import { StyledSelectCell, } from './APITokenDrawer.styles'; import { - Permission, allScopesAreTheSame, basePermNameMap, hasAccessBeenSelectedForAllScopes, @@ -38,6 +37,8 @@ import { scopeStringToPermTuples, } from './utils'; +import type { Permission } from './utils'; + type Expiry = [string, string]; export const genExpiryTups = (): Expiry[] => { @@ -172,10 +173,6 @@ export const CreateAPITokenDrawer = (props: Props) => { form.setFieldValue('scopes', newScopes); }; - const handleExpiryChange = (e: Item) => { - form.setFieldValue('expiry', e.value); - }; - // Permission scopes with a different default when Selecting All for the specified access level. const excludedScopesFromSelectAll: ExcludedScope[] = [ { @@ -214,11 +211,26 @@ export const CreateAPITokenDrawer = (props: Props) => { value={form.values.label} /> - { + setFieldValue(`security_questions[${index}]`, { + id: item.value, + question: item.label, + response: '', + }); + }} + autoHighlight defaultValue={currentOption} - isClearable={false} + disableClearable label={label} - name={name} - onChange={onChange} options={options} placeholder="Select a question" + value={options.find((option) => option.value === questionResponse?.id)} /> ); }; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx index 42ed09b940f..cab647664d9 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/QuestionAndAnswerPair.tsx @@ -1,19 +1,20 @@ -import { SecurityQuestion } from '@linode/api-v4/lib/profile'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; -import { Item } from 'src/components/EnhancedSelect'; import { Answer } from './Answer'; import { Question } from './Question'; +import type { SelectQuestionOption } from './Question'; +import type { SecurityQuestion } from '@linode/api-v4/lib/profile'; + interface Props { edit: boolean; handleChange: any; index: number; onEdit: () => void; - options: Item[]; + options: SelectQuestionOption[]; questionResponse: SecurityQuestion | undefined; securityQuestionRef?: React.RefObject; setFieldValue: (field: string, value: SecurityQuestion | number) => void; diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx index e3e94fe5e58..d011a5ce24d 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.test.tsx @@ -53,13 +53,11 @@ describe('Timezone change form', () => { }); it("should include text with the user's current time zone", async () => { - const { getByText } = renderWithTheme( + const { queryByTestId } = renderWithTheme( , { queryClient } ); - - expect(getByText('New York', { exact: false })).toBeInTheDocument(); - expect(getByText('Eastern Time', { exact: false })).toBeInTheDocument(); + expect(queryByTestId('admin-notice')).toHaveTextContent('America/New_York'); }); }); diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 3e871764f68..884fea18900 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -5,14 +5,12 @@ import * as React from 'react'; import timezones from 'src/assets/timezones/timezones'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Box } from 'src/components/Box'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { CircleProgress } from 'src/components/CircleProgress'; -import Select from 'src/components/EnhancedSelect/Select'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; -import type { Item } from 'src/components/EnhancedSelect/Select'; - interface Props { loggedInAsCustomer: boolean; } @@ -23,6 +21,11 @@ interface Timezone { offset: number; } +export interface TimezoneOption { + label: L; + value: T; +} + export const formatOffset = ({ label, offset }: Timezone) => { const minutes = (Math.abs(offset) % 60).toLocaleString(undefined, { minimumIntegerDigits: 2, @@ -34,7 +37,7 @@ export const formatOffset = ({ label, offset }: Timezone) => { return `\(GMT ${isPositive}${hours}:${minutes}\) ${label}`; }; -const renderTimeZonesList = (): Item[] => { +const renderTimezonesList = (): TimezoneOption[] => { return timezones .map((tz) => ({ ...tz, offset: DateTime.now().setZone(tz.name).offset })) .sort((a, b) => a.offset - b.offset) @@ -44,35 +47,38 @@ const renderTimeZonesList = (): Item[] => { }); }; -const timezoneList = renderTimeZonesList(); +const timezoneList = renderTimezonesList(); export const TimezoneForm = (props: Props) => { const { loggedInAsCustomer } = props; const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); + const [timezoneValue, setTimezoneValue] = React.useState< + TimezoneOption | string + >(''); const { error, isPending, mutateAsync: updateProfile } = useMutateProfile(); - const [value, setValue] = React.useState | null>(null); const timezone = profile?.timezone ?? ''; - const handleTimezoneChange = (timezone: Item) => { - setValue(timezone); + const handleTimezoneChange = (timezone: TimezoneOption) => { + setTimezoneValue(timezone); }; const onSubmit = () => { - if (value === null) { + if (timezoneValue === '') { return; } - updateProfile({ timezone: String(value.value) }).then(() => { + updateProfile({ timezone: String(timezoneValue) }).then(() => { enqueueSnackbar('Successfully updated timezone', { variant: 'success' }); }); }; - const defaultTimeZone = timezoneList.find((eachZone) => { + const defaultTimezone = timezoneList.find((eachZone) => { return eachZone.value === timezone; }); - const disabled = value === null || defaultTimeZone?.value === value?.value; + const disabled = + timezoneValue === '' || defaultTimezone?.value === timezoneValue; if (!profile) { return ; @@ -88,27 +94,28 @@ export const TimezoneForm = (props: Props) => { ) : null} - ({ - [theme.breakpoints.down('md')]: { - alignItems: 'flex-start', - flexDirection: 'column', - }, - })} - alignItems="flex-end" - display="flex" - justifyContent="space-between" - > - + ) => setLishAuthMethod(item.value)} textFieldProps={{ dataAttrs: { 'data-qa-mode-select': true, }, tooltipText, }} + value={modeOptions.find( + (option) => option.value === lishAuthMethod + )} defaultValue={defaultMode} + disableClearable errorText={authMethodError} + getOptionDisabled={(option) => option.disabled === true} id="mode-select" - isClearable={false} label="Authentication Mode" - name="mode-select" - onChange={onListAuthMethodChange as any} options={modeOptions} /> diff --git a/packages/manager/src/features/Profile/Profile.tsx b/packages/manager/src/features/Profile/Profile.tsx index 39351969629..3e618aae6d3 100644 --- a/packages/manager/src/features/Profile/Profile.tsx +++ b/packages/manager/src/features/Profile/Profile.tsx @@ -3,7 +3,9 @@ import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; -import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; +import { NavTabs } from 'src/components/NavTabs/NavTabs'; + +import type { NavTab } from 'src/components/NavTabs/NavTabs'; const SSHKeys = React.lazy(() => import('./SSHKeys/SSHKeys').then((module) => ({ @@ -42,7 +44,7 @@ const APITokens = React.lazy(() => })) ); -const Profile = () => { +export const Profile = () => { const { url } = useRouteMatch(); const tabs: NavTab[] = [ @@ -96,5 +98,3 @@ const Profile = () => { ); }; - -export default Profile;