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,
+ });
+ },
+});