diff --git a/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md b/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md new file mode 100644 index 00000000000..e820b5df98c --- /dev/null +++ b/packages/manager/.changeset/pr-10100-upcoming-features-1706825389536.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Create Placement Groups Select component ([#10100](https://github.com/linode/manager/pull/10100)) diff --git a/packages/manager/.changeset/pr-10142-fixed-1707158870254.md b/packages/manager/.changeset/pr-10142-fixed-1707158870254.md new file mode 100644 index 00000000000..8b07c826f38 --- /dev/null +++ b/packages/manager/.changeset/pr-10142-fixed-1707158870254.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Unit tests Button enabled assertions ([#10142](https://github.com/linode/manager/pull/10142)) diff --git a/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md b/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md new file mode 100644 index 00000000000..8e5fc42a957 --- /dev/null +++ b/packages/manager/.changeset/pr-10146-tech-stories-1707162412490.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +DC Get Well - Cleanup/Remove feature flag logic ([#10146](https://github.com/linode/manager/pull/10146)) diff --git a/packages/manager/.changeset/pr-10150-tests-1707236804326.md b/packages/manager/.changeset/pr-10150-tests-1707236804326.md new file mode 100644 index 00000000000..ffee439421b --- /dev/null +++ b/packages/manager/.changeset/pr-10150-tests-1707236804326.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix billing contact Cypress test by narrowing element selection scope ([#10150](https://github.com/linode/manager/pull/10150)) diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index be8fd90997c..0db765463af 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -1,6 +1,7 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; +import { ui } from 'support/ui'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -66,75 +67,82 @@ describe('Billing Contact', () => { // mock the user's account data and confirm that it is displayed correctly upon page load mockGetAccount(accountData).as('getAccount'); cy.visitWithLogin('/account/billing'); - checkAccountContactDisplay(accountData); // edit the billing contact information mockUpdateAccount(newAccountData).as('updateAccount'); cy.get('[data-qa-contact-summary]').within((_contact) => { + checkAccountContactDisplay(accountData); cy.findByText('Edit').should('be.visible').click(); }); - // check drawer is visible - cy.findByLabelText('First Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['first_name']); - cy.findByLabelText('Last Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['last_name']); - cy.findByLabelText('Company Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['company']); - cy.findByLabelText('Address') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_1']); - cy.findByLabelText('Address 2') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_2']); - cy.findByLabelText('Email (required)') - .should('be.visible') - .click() - .clear() - .type(newAccountData['email']); - cy.findByLabelText('City') - .should('be.visible') - .click() - .clear() - .type(newAccountData['city']); - cy.findByLabelText('Postal Code') - .should('be.visible') - .click() - .clear() - .type(newAccountData['zip']); - cy.findByLabelText('Phone') - .should('be.visible') - .click() - .clear() - .type(newAccountData['phone']); - cy.get('[data-qa-contact-country]').click().type('United States{enter}'); - cy.get('[data-qa-contact-state-province]') - .should('be.visible') - .click() - .type(`${newAccountData['state']}{enter}`); - cy.findByLabelText('Tax ID') + + ui.drawer + .findByTitle('Edit Billing Contact Info') .should('be.visible') - .click() - .clear() - .type(newAccountData['tax_id']); - cy.get('[data-qa-save-contact-info="true"]') - .click() - .then(() => { - cy.wait('@updateAccount').then((xhr) => { - expect(xhr.response?.body).to.eql(newAccountData); - }); + .within(() => { + cy.findByLabelText('First Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['first_name']); + cy.findByLabelText('Last Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['last_name']); + cy.findByLabelText('Company Name') + .should('be.visible') + .click() + .clear() + .type(newAccountData['company']); + cy.findByLabelText('Address') + .should('be.visible') + .click() + .clear() + .type(newAccountData['address_1']); + cy.findByLabelText('Address 2') + .should('be.visible') + .click() + .clear() + .type(newAccountData['address_2']); + cy.findByLabelText('Email (required)') + .should('be.visible') + .click() + .clear() + .type(newAccountData['email']); + cy.findByLabelText('City') + .should('be.visible') + .click() + .clear() + .type(newAccountData['city']); + cy.findByLabelText('Postal Code') + .should('be.visible') + .click() + .clear() + .type(newAccountData['zip']); + cy.findByLabelText('Phone') + .should('be.visible') + .click() + .clear() + .type(newAccountData['phone']); + cy.get('[data-qa-contact-country]') + .click() + .type('United States{enter}'); + cy.get('[data-qa-contact-state-province]') + .should('be.visible') + .click() + .type(`${newAccountData['state']}{enter}`); + cy.findByLabelText('Tax ID') + .should('be.visible') + .click() + .clear() + .type(newAccountData['tax_id']); + cy.get('[data-qa-save-contact-info="true"]') + .click() + .then(() => { + cy.wait('@updateAccount').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountData); + }); + }); }); // check the page updates to reflect the edits diff --git a/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx b/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx index abe3a9e89cb..a733093124d 100644 --- a/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx +++ b/packages/manager/src/components/LabelAndTagsPanel/LabelAndTagsPanel.tsx @@ -1,20 +1,46 @@ +import { Region } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; +import { Box } from 'src/components/Box'; import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { + PlacementGroupsSelect, + PlacementGroupsSelectProps, +} from 'src/components/PlacementGroupsSelect/PlacementGroupsSelect'; import { TagsInput, TagsInputProps } from 'src/components/TagsInput/TagsInput'; import { TextField, TextFieldProps } from 'src/components/TextField'; -import { Paper } from 'src/components/Paper'; +import { TooltipIcon } from 'src/components/TooltipIcon'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +{ + /* TODO VM_Placement: 'Learn more' Link */ +} +const tooltipText = ` +Add your virtual machine (VM) to a group to best meet your needs. +You may want to group VMs closer together to help improve performance, or further apart to enable high-availability configurations. +Learn more.`; interface LabelAndTagsProps { error?: string; labelFieldProps?: TextFieldProps; + placementGroupsSelectProps?: PlacementGroupsSelectProps; + regions?: Region[]; tagsInputProps?: TagsInputProps; } export const LabelAndTagsPanel = (props: LabelAndTagsProps) => { const theme = useTheme(); - const { error, labelFieldProps, tagsInputProps } = props; + const flags = useFlags(); + const showPlacementGroups = Boolean(flags.vmPlacement); + const { + error, + labelFieldProps, + placementGroupsSelectProps, + tagsInputProps, + } = props; return ( { noMarginTop /> {tagsInputProps && } + {showPlacementGroups && ( + <> + {!placementGroupsSelectProps?.selectedRegionId && ( + + + Select a region above to see available Placement Groups. + + + )} + {placementGroupsSelectProps && ( + + + + + )} + + )} ); }; diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx new file mode 100644 index 00000000000..54a2180817c --- /dev/null +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.test.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + PlacementGroupsSelect, + PlacementGroupsSelectProps, +} from './PlacementGroupsSelect'; + +const props: PlacementGroupsSelectProps = { + errorText: '', + handlePlacementGroupSelection: vi.fn(), + id: '', + label: 'Placement Groups in Atlanta, GA (us-southeast)', + noOptionsMessage: '', + selectedRegionId: 'us-southeast', +}; + +describe('PlacementGroupSelect', () => { + it('should render a Select component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('placement-groups-select')).toBeInTheDocument(); + }); + + it('should render a Select component with the correct label', () => { + const { getByText } = renderWithTheme(); + expect(getByText(/Placement Groups in /)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx new file mode 100644 index 00000000000..a3cca9d5582 --- /dev/null +++ b/packages/manager/src/components/PlacementGroupsSelect/PlacementGroupsSelect.tsx @@ -0,0 +1,110 @@ +import { PlacementGroup } from '@linode/api-v4'; +import { APIError } from '@linode/api-v4/lib/types'; +import { SxProps } from '@mui/system'; +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useUnpaginatedPlacementGroupsQuery } from 'src/queries/placementGroups'; + +export interface PlacementGroupsSelectProps { + clearable?: boolean; + disabled?: boolean; + errorText?: string; + handlePlacementGroupSelection: (selected: PlacementGroup) => void; + id?: string; + label: string; + loading?: boolean; + noOptionsMessage?: string; + onBlur?: (e: React.FocusEvent) => void; + renderOption?: ( + placementGroup: PlacementGroup, + selected: boolean + ) => JSX.Element; + renderOptionLabel?: (placementGroups: PlacementGroup) => string; + selectedRegionId?: string; + sx?: SxProps; +} + +export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => { + const { + clearable = true, + disabled, + errorText, + handlePlacementGroupSelection, + id, + label, + loading, + noOptionsMessage, + onBlur, + renderOption, + renderOptionLabel, + selectedRegionId, + sx, + } = props; + + const { + data: placementGroups, + error, + isLoading, + } = useUnpaginatedPlacementGroupsQuery(Boolean(selectedRegionId)); + + const placementGroupsOptions = placementGroups?.filter( + (placementGroup) => placementGroup.region === selectedRegionId + ); + + const handlePlacementGroupChange = (selection: PlacementGroup) => { + handlePlacementGroupSelection(selection); + }; + + return ( + + renderOptionLabel + ? renderOptionLabel(placementGroupsOptions) + : `${placementGroupsOptions.label} (${placementGroupsOptions.affinity_type})` + } + noOptionsText={ + noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) + } + onChange={(_, selectedOption: PlacementGroup) => { + handlePlacementGroupChange(selectedOption); + }} + renderOption={ + renderOption + ? (props, option, { selected }) => { + return ( +
  • + {renderOption(option, selected)} +
  • + ); + } + : undefined + } + clearOnBlur={false} + data-testid="placement-groups-select" + disableClearable={!clearable} + disabled={disabled} + errorText={errorText} + id={id} + key={selectedRegionId} + label={label} + loading={isLoading || loading} + onBlur={onBlur} + options={placementGroupsOptions ?? []} + placeholder="Select a Placement Group" + sx={sx} + /> + ); +}; + +const getDefaultNoOptionsMessage = ( + error: APIError[] | null, + loading: boolean +) => { + if (error) { + return 'An error occurred while fetching your Placement Groups'; + } + return loading + ? 'Loading your Placement Groups...' + : 'No available Placement Groups'; +}; diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index b9acc2d0a1e..4b37ee42435 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; import { RegionOption } from './RegionOption'; @@ -36,11 +35,10 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { width, } = props; - const flags = useFlags(); const { data: accountAvailability, isLoading: accountAvailabilityLoading, - } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + } = useAccountAvailabilitiesQueryUnpaginated(); const [selectedRegions, setSelectedRegions] = useState( getSelectedRegionsByIds({ @@ -93,9 +91,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { <> - Boolean(flags.dcGetWell) && Boolean(option.unavailable) - } groupBy={(option: RegionSelectOption) => { return option?.data?.region; }} @@ -134,6 +129,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option: RegionSelectOption) => option.unavailable} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 5b4279666b5..38fe2feaccd 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -5,7 +5,6 @@ import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; import { Tooltip } from 'src/components/Tooltip'; -import { useFlags } from 'src/hooks/useFlags'; import { SelectedIcon, @@ -23,9 +22,7 @@ type Props = { }; export const RegionOption = ({ option, props, selected }: Props) => { - const flags = useFlags(); - const isDisabledMenuItem = - Boolean(flags.dcGetWell) && Boolean(option.unavailable); + const isDisabledMenuItem = option.unavailable; return ( { width, } = props; - const flags = useFlags(); const { data: accountAvailability, isLoading: accountAvailabilityLoading, - } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + } = useAccountAvailabilitiesQueryUnpaginated(); const regionFromSelectedId: RegionSelectOption | null = getSelectedRegionById({ @@ -84,9 +82,6 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { return ( - Boolean(flags.dcGetWell) && Boolean(option.unavailable) - } isOptionEqualToValue={( option: RegionSelectOption, { value }: RegionSelectOption @@ -127,6 +122,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option: RegionSelectOption) => option.unavailable} groupBy={(option: RegionSelectOption) => option.data.region} label={label ?? 'Region'} loading={accountAvailabilityLoading} diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index af174364a63..7a5b7586832 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -14,7 +14,6 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, - { flag: 'dcGetWell', label: 'DC Get Well' }, { flag: 'linodeCloneUIChanges', label: 'Linode Clone UI Changes' }, { flag: 'metadata', label: 'Metadata' }, { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 0c2127f8336..c622dda8443 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -46,7 +46,6 @@ export interface Flags { databaseBeta: boolean; databaseScaleUp: boolean; databases: boolean; - dcGetWell: boolean; firewallNodebalancer: boolean; ipv6Sharing: boolean; linodeCloneUIChanges: boolean; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 1f7b58a7e4b..265c82317e0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -1,3 +1,4 @@ +import { PlacementGroup } from '@linode/api-v4'; import { InterfacePayload, PriceObject, @@ -38,12 +39,13 @@ import { WithTypesProps } from 'src/containers/types.container'; import { FeatureFlagConsumerProps } from 'src/containers/withFeatureFlagConsumer.container'; import { WithLinodesProps } from 'src/containers/withLinodes.container'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; +import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { getMonthlyAndHourlyNodePricing, utoa, } from 'src/features/Linodes/LinodesCreate/utilities'; -import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { SMTPRestrictionText } from 'src/features/Linodes/SMTPRestrictionText'; +import { getPlacementGroupLinodeCount } from 'src/features/PlacementGroups/utils'; import { getCommunityStackscripts, getMineAndAccountStackScripts, @@ -117,6 +119,7 @@ export interface LinodeCreateProps { imageDisplayInfo: Info; ipamAddress: null | string; label: string; + placementGroupSelection?: PlacementGroup; regionDisplayInfo: Info; resetCreationState: () => void; selectedSubnetId?: number; @@ -137,6 +140,7 @@ export interface LinodeCreateProps { updateLabel: (label: string) => void; updateLinodeID: (id: number, diskSize?: number | undefined) => void; updatePassword: (password: string) => void; + updatePlacementGroupSelection: (placementGroup: PlacementGroup) => void; updateTags: (tags: Tag[]) => void; updateUserData: (userData: string) => void; userData: string | undefined; @@ -274,6 +278,7 @@ export class LinodeCreate extends React.PureComponent< linodesData, linodesError, linodesLoading, + placementGroupSelection, regionDisplayInfo, regionsData, regionsError, @@ -289,6 +294,7 @@ export class LinodeCreate extends React.PureComponent< typesError, typesLoading, updateLabel, + updatePlacementGroupSelection, updateTags, updateUserData, userCannotCreateLinode, @@ -312,6 +318,18 @@ export class LinodeCreate extends React.PureComponent< return null; } + { + /* TODO VM_Placement: Refactor this into a util method */ + } + const regionLabel = regionsData?.find((r) => r.id === selectedRegionID) + ?.label; + let placementGroupsLabel; + if (selectedRegionID && regionLabel) { + placementGroupsLabel = `Placement Groups in ${regionLabel} (${selectedRegionID})`; + } else { + placementGroupsLabel = 'Placement Group'; + } + const tagsInputProps = { disabled: userCannotCreateLinode, onChange: updateTags, @@ -319,6 +337,15 @@ export class LinodeCreate extends React.PureComponent< value: tags || [], }; + let errorText; + if ( + placementGroupSelection && + getPlacementGroupLinodeCount(placementGroupSelection) >= + placementGroupSelection.capacity + ) { + errorText = `This Placement Group doesn't have any capacity`; + } + const hasBackups = Boolean( this.props.backupsEnabled || accountBackupsEnabled ); @@ -610,12 +637,21 @@ export class LinodeCreate extends React.PureComponent< onChange: (e) => updateLabel(e.target.value), value: label || '', }} + placementGroupsSelectProps={{ + disabled: !selectedRegionID, + errorText, + handlePlacementGroupSelection: updatePlacementGroupSelection, + label: placementGroupsLabel, + noOptionsMessage: 'There are no Placement Groups in this region', + selectedRegionId: selectedRegionID, + }} tagsInputProps={ this.props.createType !== 'fromLinode' ? tagsInputProps : undefined } data-qa-label-and-tags-panel + regions={regionsData!} /> {/* Hide for backups and clone */} {!['fromBackup', 'fromLinode'].includes(this.props.createType) && ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index 72ea5e286bb..84863f4f2f7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -1,3 +1,4 @@ +import { PlacementGroup } from '@linode/api-v4'; import { Agreements, signAgreement } from '@linode/api-v4/lib/account'; import { Image } from '@linode/api-v4/lib/images'; import { Region } from '@linode/api-v4/lib/regions'; @@ -105,6 +106,7 @@ interface State { errors?: APIError[]; formIsSubmitting: boolean; password: string; + placementGroupSelection?: PlacementGroup; privateIPEnabled: boolean; selectedBackupID?: number; selectedDiskSize?: number; @@ -154,6 +156,7 @@ const defaultState: State = { errors: undefined, formIsSubmitting: false, password: '', + placementGroupSelection: undefined, privateIPEnabled: false, selectedBackupID: undefined, selectedDiskSize: undefined, @@ -309,6 +312,7 @@ class LinodeCreateContainer extends React.PureComponent { updateLabel={this.updateCustomLabel} updateLinodeID={this.setLinodeID} updatePassword={this.setPassword} + updatePlacementGroupSelection={this.setPlacementGroupSelection} updateRegionID={this.setRegionID} updateStackScript={this.setStackScript} updateTags={this.setTags} @@ -596,6 +600,10 @@ class LinodeCreateContainer extends React.PureComponent { setPassword = (password: string) => this.setState({ password }); + setPlacementGroupSelection = (placementGroupSelection: PlacementGroup) => { + this.setState({ placementGroupSelection }); + }; + setRegionID = (selectedRegionId: string) => { const { showGDPRCheckbox } = getGDPRDetails({ agreements: this.props.agreements?.data, @@ -690,10 +698,10 @@ class LinodeCreateContainer extends React.PureComponent { selectedTypeID: this.params.typeID, showGDPRCheckbox: Boolean( !this.props.profile.data?.restricted && - isEURegion( - getSelectedRegionGroup(this.props.regionsData, this.params.regionID) - ) && - this.props.agreements?.data?.eu_model + isEURegion( + getSelectedRegionGroup(this.props.regionsData, this.params.regionID) + ) && + this.props.agreements?.data?.eu_model ), signedAgreement: false, }; @@ -825,10 +833,10 @@ class LinodeCreateContainer extends React.PureComponent { const request = createType === 'fromLinode' ? () => - this.props.linodeActions.cloneLinode({ - sourceLinodeId: linodeID!, - ...payload, - }) + this.props.linodeActions.cloneLinode({ + sourceLinodeId: linodeID!, + ...payload, + }) : () => this.props.linodeActions.createLinode(payload); this.setState({ formIsSubmitting: true }); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 680e1adfe79..de219030295 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -15,6 +15,8 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { getAll } from 'src/utilities/getAll'; + import { queryKey as PROFILE_QUERY_KEY } from './profile'; import type { @@ -25,6 +27,18 @@ import type { export const queryKey = 'placement-groups'; +export const useUnpaginatedPlacementGroupsQuery = (enabled = true) => + useQuery({ + enabled, + queryFn: () => getAllPlacementGroupsRequest(), + queryKey: [queryKey, 'all'], + }); + +const getAllPlacementGroupsRequest = () => + getAll((params, filters) => + getPlacementGroups(params, filters) + )().then((data) => data.data); + export const usePlacementGroupsQuery = ( params: Params, filter: Filter, diff --git a/packages/manager/src/testSetup.ts b/packages/manager/src/testSetup.ts index e7d6f33aa01..1660a6821b6 100644 --- a/packages/manager/src/testSetup.ts +++ b/packages/manager/src/testSetup.ts @@ -1,9 +1,8 @@ +import matchers from '@testing-library/jest-dom/matchers'; import Enzyme from 'enzyme'; // @ts-expect-error not a big deal, we can suffer import Adapter from 'enzyme-adapter-react-16'; - import { expect } from 'vitest'; -import matchers from '@testing-library/jest-dom/matchers'; // // Enzyme React 17 adapter. // Enzyme.configure({ adapter: new Adapter() }); @@ -60,3 +59,75 @@ vi.mock('highlight.js/lib/highlight', () => ({ registerLanguage: vi.fn(), }, })); + +/** + *************************************** + * Custom matchers & matchers overrides + *************************************** + */ + +/** + * Matcher override for toBeDisabled and toBeEnabled + * + * The reason for overriding those matchers is that we need to check for the aria-disabled attribute as well. + * When a button is disabled, it will not necessarily have the `disabled` attribute. but it will have an aria-disabled attribute set to true. + */ +const ariaDisabledAttribute = 'aria-disabled'; + +const isElementDisabled = (element: HTMLElement) => { + // We really only want to check for `aria-disabled` on buttons since this is a Cloud Manager customization + return element.tagName.toLowerCase() === 'button' + ? element.getAttribute(ariaDisabledAttribute) === 'true' || + element.hasAttribute('disabled') + : element.hasAttribute('disabled'); +}; + +interface HandleResult { + condition: boolean; + element: HTMLElement; + expectedState: 'disabled' | 'enabled'; + thisInstance: any; +} + +const handleResult = ({ + condition, + element, + expectedState, + thisInstance, +}: HandleResult) => { + const message = `${thisInstance?.utils?.printReceived( + element ?? '' + )}\n\n expected ${element?.tagName} to be ${expectedState}`; + return condition + ? { + message: () => '', + pass: true, + } + : { + message: () => message, + pass: false, + }; +}; + +expect.extend({ + toBeDisabled(this: any, element: HTMLElement) { + const isDisabled = isElementDisabled(element); + + return handleResult({ + condition: isDisabled, + element, + expectedState: 'disabled', + thisInstance: this, + }); + }, + toBeEnabled(this: any, element: HTMLElement) { + const isEnabled = !isElementDisabled(element); + + return handleResult({ + condition: isEnabled, + element, + expectedState: 'enabled', + thisInstance: this, + }); + }, +});