diff --git a/packages/manager/.changeset/pr-10372-upcoming-features-1712891100686.md b/packages/manager/.changeset/pr-10372-upcoming-features-1712891100686.md
new file mode 100644
index 00000000000..d7ec92b0c1d
--- /dev/null
+++ b/packages/manager/.changeset/pr-10372-upcoming-features-1712891100686.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Fix & Improve Placement Groups feature restriction ([#10372](https://github.com/linode/manager/pull/10372))
diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts
index 981c0aa2c5b..7709b82b298 100644
--- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts
+++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts
@@ -5,8 +5,12 @@ import {
import { makeFeatureFlagData } from 'support/util/feature-flags';
import { mockGetPlacementGroups } from 'support/intercepts/vm-placement';
import { ui } from 'support/ui';
+import { accountFactory } from 'src/factories';
import type { Flags } from 'src/featureFlags';
+import { mockGetAccount } from 'support/intercepts/account';
+
+const mockAccount = accountFactory.build();
describe('VM Placement landing page', () => {
// Mock the VM Placement Groups feature flag to be enabled for each test in this block.
@@ -18,6 +22,7 @@ describe('VM Placement landing page', () => {
}),
});
mockGetFeatureFlagClientstream();
+ mockGetAccount(mockAccount).as('getAccount');
});
/**
diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx
index a682323fcb8..fd55ff3a646 100644
--- a/packages/manager/src/GoTo.tsx
+++ b/packages/manager/src/GoTo.tsx
@@ -7,6 +7,7 @@ import { makeStyles } from 'tss-react/mui';
import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select';
import { useIsACLBEnabled } from './features/LoadBalancers/utils';
+import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils';
import { useAccountManagement } from './hooks/useAccountManagement';
import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener';
@@ -60,6 +61,7 @@ export const GoTo = React.memo(() => {
const { _hasAccountAccess, _isManagedAccount } = useAccountManagement();
const { isACLBEnabled } = useIsACLBEnabled();
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
const { goToOpen, setGoToOpen } = useGlobalKeyboardListener();
const onClose = () => {
@@ -113,6 +115,11 @@ export const GoTo = React.memo(() => {
display: 'Images',
href: '/images',
},
+ {
+ display: 'Placement Groups',
+ hide: !isPlacementGroupsEnabled,
+ href: '/placement-groups',
+ },
{
display: 'Domains',
href: '/domains',
@@ -149,7 +156,12 @@ export const GoTo = React.memo(() => {
href: '/profile/display',
},
],
- [_hasAccountAccess, _isManagedAccount, isACLBEnabled]
+ [
+ _hasAccountAccess,
+ _isManagedAccount,
+ isACLBEnabled,
+ isPlacementGroupsEnabled,
+ ]
);
const options: Item[] = React.useMemo(
diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx
index 46bd8cf1a07..c42231b0688 100644
--- a/packages/manager/src/MainContent.tsx
+++ b/packages/manager/src/MainContent.tsx
@@ -1,5 +1,5 @@
-import Grid from '@mui/material/Unstable_Grid2';
import { Theme } from '@mui/material/styles';
+import Grid from '@mui/material/Unstable_Grid2';
import { isEmpty } from 'ramda';
import * as React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
@@ -10,8 +10,8 @@ import { Box } from 'src/components/Box';
import { MainContentBanner } from 'src/components/MainContentBanner';
import { MaintenanceScreen } from 'src/components/MaintenanceScreen';
import { NotFound } from 'src/components/NotFound';
-import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu';
import { SideMenu } from 'src/components/PrimaryNav/SideMenu';
+import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import { useDialogContext } from 'src/context/useDialogContext';
import { Footer } from 'src/features/Footer';
@@ -33,6 +33,7 @@ import { complianceUpdateContext } from './context/complianceUpdateContext';
import { switchAccountSessionContext } from './context/switchAccountSessionContext';
import { FlagSet } from './featureFlags';
import { useIsACLBEnabled } from './features/LoadBalancers/utils';
+import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils';
import { useGlobalErrors } from './hooks/useGlobalErrors';
const useStyles = makeStyles()((theme: Theme) => ({
@@ -226,6 +227,7 @@ export const MainContent = () => {
(checkRestrictedUser && !enginesLoading && !enginesError);
const { isACLBEnabled } = useIsACLBEnabled();
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
const defaultRoot = _isManagedAccount ? '/managed' : '/linodes';
@@ -337,10 +339,12 @@ export const MainContent = () => {
}>
-
+ {isPlacementGroupsEnabled && (
+
+ )}
{isACLBEnabled && (
diff --git a/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx b/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx
index 0f93f1b77ee..e1e09ef6645 100644
--- a/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx
+++ b/packages/manager/src/components/DetailsPanel/DetailsPanel.tsx
@@ -7,7 +7,7 @@ import { TagsInput, TagsInputProps } from 'src/components/TagsInput/TagsInput';
import { TextField, TextFieldProps } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
import { PlacementGroupsDetailPanel } from 'src/features/PlacementGroups/PlacementGroupsDetailPanel';
-import { useFlags } from 'src/hooks/useFlags';
+import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils';
import type { PlacementGroup } from '@linode/api-v4';
@@ -28,9 +28,7 @@ export const DetailsPanel = (props: DetailsPanelProps) => {
tagsInputProps,
} = props;
const theme = useTheme();
- const flags = useFlags();
-
- const showPlacementGroups = Boolean(flags.placementGroups?.enabled);
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
return (
{
/>
{tagsInputProps && }
-
- {showPlacementGroups && (
+ {isPlacementGroupsEnabled && (
{
(checkRestrictedUser && !enginesLoading && !enginesError);
const { isACLBEnabled } = useIsACLBEnabled();
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
const prefetchObjectStorage = () => {
if (!enableObjectPrefetch) {
@@ -245,7 +247,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
{
betaChipClassName: 'beta-chip-placement-groups',
display: 'Placement Groups',
- hide: !flags.placementGroups?.enabled,
+ hide: !isPlacementGroupsEnabled,
href: '/placement-groups',
icon: ,
isBeta: flags.placementGroups?.beta,
@@ -322,6 +324,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
allowMarketplacePrefetch,
flags.databaseBeta,
isACLBEnabled,
+ isPlacementGroupsEnabled,
flags.placementGroups,
]
);
diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts
index 17662c422de..79f8866b75d 100644
--- a/packages/manager/src/factories/account.ts
+++ b/packages/manager/src/factories/account.ts
@@ -46,6 +46,7 @@ export const accountFactory = Factory.Sync.makeFactory({
'LKE HA Control Planes',
'Machine Images',
'Managed Databases',
+ 'Placement Group',
],
city: 'Colorado',
company: Factory.each((i) => `company-${i}`),
diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx
index b54884d9f20..153792f7441 100644
--- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx
@@ -1,8 +1,7 @@
import { waitFor } from '@testing-library/react';
import React from 'react';
-import { profileFactory } from 'src/factories';
-import { grantsFactory } from 'src/factories/grants';
+import { grantsFactory, profileFactory } from 'src/factories';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';
@@ -37,7 +36,7 @@ describe('Linode Create Details', () => {
expect(getByText('Type to choose or create a tag.')).toBeVisible();
});
- it('renders an placement group details if the flag is on', () => {
+ it('renders an placement group details if the flag is on', async () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: ,
options: {
@@ -45,9 +44,11 @@ describe('Linode Create Details', () => {
},
});
- expect(
- getByText('Select a region above to see available Placement Groups.')
- ).toBeVisible();
+ await waitFor(() => {
+ expect(
+ getByText('Select a region above to see available Placement Groups.')
+ ).toBeVisible();
+ });
});
it('does not render the placement group select if the flag is off', () => {
diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx
index ed1fc1b205a..914c3f78cd7 100644
--- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx
@@ -6,16 +6,14 @@ import { Paper } from 'src/components/Paper';
import { TagsInput } from 'src/components/TagsInput/TagsInput';
import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
-import { useFlags } from 'src/hooks/useFlags';
+import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { PlacementGroupPanel } from './PlacementGroupPanel';
export const Details = () => {
const { control } = useFormContext();
- const flags = useFlags();
-
- const showPlacementGroups = Boolean(flags.placementGroups?.enabled);
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
const isCreateLinodeRestricted = useRestrictedGlobalGrantCheck({
globalGrantType: 'add_linodes',
@@ -51,7 +49,7 @@ export const Details = () => {
control={control}
name="tags"
/>
- {showPlacementGroups && }
+ {isPlacementGroupsEnabled && }
);
};
diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx
index 4d8efbd6054..1b3c7f7ac15 100644
--- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx
+++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx
@@ -861,10 +861,7 @@ export class LinodeCreate extends React.PureComponent<
image: this.props.selectedImageID,
label: this.props.label,
placement_group:
- this.props.flags.placementGroups?.enabled &&
- placement_group_payload.id !== -1
- ? placement_group_payload
- : undefined,
+ placement_group_payload.id !== -1 ? placement_group_payload : undefined,
private_ip: this.props.privateIPEnabled,
region: this.props.selectedRegionID ?? '',
root_pass: this.props.password,
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx
index 5f4a3e78a0c..ab73505e212 100644
--- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsDetail.tsx
@@ -9,7 +9,6 @@ import { LandingHeader } from 'src/components/LandingHeader';
import { NotFound } from 'src/components/NotFound';
import { Notice } from 'src/components/Notice/Notice';
import { getRestrictedResourceText } from 'src/features/Account/utils';
-import { useFlags } from 'src/hooks/useFlags';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { useAllLinodesQuery } from 'src/queries/linodes/linodes';
import {
@@ -23,7 +22,6 @@ import { PlacementGroupsLinodes } from './PlacementGroupsLinodes/PlacementGroups
import { PlacementGroupsSummary } from './PlacementGroupsSummary/PlacementGroupsSummary';
export const PlacementGroupsDetail = () => {
- const flags = useFlags();
const { id } = useParams<{ id: string }>();
const placementGroupId = +id;
@@ -31,10 +29,7 @@ export const PlacementGroupsDetail = () => {
data: placementGroup,
error: placementGroupError,
isLoading,
- } = usePlacementGroupQuery(
- placementGroupId,
- Boolean(flags.placementGroups?.enabled)
- );
+ } = usePlacementGroupQuery(placementGroupId);
const { data: linodes, isFetching: isFetchingLinodes } = useAllLinodesQuery(
{},
{
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx
index aea98d6e272..ea4a1db2303 100644
--- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx
@@ -10,7 +10,6 @@ import { TextTooltip } from 'src/components/TextTooltip';
import { Typography } from 'src/components/Typography';
import { PlacementGroupsCreateDrawer } from 'src/features/PlacementGroups/PlacementGroupsCreateDrawer';
import { hasRegionReachedPlacementGroupCapacity } from 'src/features/PlacementGroups/utils';
-import { useFlags } from 'src/hooks/useFlags';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { useAllPlacementGroupsQuery } from 'src/queries/placementGroups';
import { useRegionsQuery } from 'src/queries/regions/regions';
@@ -26,7 +25,6 @@ interface Props {
}
export const PlacementGroupsDetailPanel = (props: Props) => {
- const flags = useFlags();
const theme = useTheme();
const { handlePlacementGroupChange, selectedRegionId } = props;
const { data: allPlacementGroups } = useAllPlacementGroupsQuery();
@@ -151,16 +149,14 @@ export const PlacementGroupsDetailPanel = (props: Props) => {
)}
- {flags.placementGroups?.enabled && (
- setIsCreatePlacementGroupDrawerOpen(false)}
- onPlacementGroupCreate={handlePlacementGroupCreated}
- open={isCreatePlacementGroupDrawerOpen}
- selectedRegionId={selectedRegionId}
- />
- )}
+ setIsCreatePlacementGroupDrawerOpen(false)}
+ onPlacementGroupCreate={handlePlacementGroupCreated}
+ open={isCreatePlacementGroupDrawerOpen}
+ selectedRegionId={selectedRegionId}
+ />
>
);
};
diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts
index ac6c9df18f4..28d652fdd30 100644
--- a/packages/manager/src/features/PlacementGroups/utils.test.ts
+++ b/packages/manager/src/features/PlacementGroups/utils.test.ts
@@ -1,3 +1,5 @@
+import { renderHook } from '@testing-library/react';
+
import {
linodeFactory,
placementGroupFactory,
@@ -11,8 +13,30 @@ import {
getPlacementGroupLinodes,
hasPlacementGroupReachedCapacity,
hasRegionReachedPlacementGroupCapacity,
+ useIsPlacementGroupsEnabled,
} from './utils';
+const queryMocks = vi.hoisted(() => ({
+ useAccount: vi.fn().mockReturnValue({}),
+ useFlags: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/account/account', () => {
+ const actual = vi.importActual('src/queries/account/account');
+ return {
+ ...actual,
+ useAccount: queryMocks.useAccount,
+ };
+});
+
+vi.mock('src/hooks/useFlags', () => {
+ const actual = vi.importActual('src/hooks/useFlags');
+ return {
+ ...actual,
+ useFlags: queryMocks.useFlags,
+ };
+});
+
const initialLinodeData = [
{
is_compliant: true,
@@ -201,3 +225,58 @@ describe('getPlacementGroupLinodes', () => {
expect(getPlacementGroupLinodes(placementGroup, linodes)).toBeUndefined();
});
});
+
+describe('useIsPlacementGroupsEnabled', () => {
+ it('returns true if the feature flag is enabled and the account has the Placement Group capability', () => {
+ queryMocks.useFlags.mockReturnValue({
+ placementGroups: {
+ enabled: true,
+ },
+ });
+ queryMocks.useAccount.mockReturnValue({
+ data: {
+ capabilities: ['Placement Group'],
+ },
+ });
+
+ const { result } = renderHook(() => useIsPlacementGroupsEnabled());
+ expect(result.current).toStrictEqual({
+ isPlacementGroupsEnabled: true,
+ });
+ });
+
+ it('returns false if the feature flag is disabled', () => {
+ queryMocks.useFlags.mockReturnValue({
+ placementGroups: {
+ enabled: false,
+ },
+ });
+ queryMocks.useAccount.mockReturnValue({
+ data: {
+ capabilities: ['Placement Group'],
+ },
+ });
+
+ const { result } = renderHook(() => useIsPlacementGroupsEnabled());
+ expect(result.current).toStrictEqual({
+ isPlacementGroupsEnabled: false,
+ });
+ });
+ it('returns false if the account does not have the Placement Group capability', () => {
+ queryMocks.useFlags.mockReturnValue({
+ placementGroups: {
+ enabled: true,
+ },
+ });
+ queryMocks.useAccount.mockReturnValue({
+ data: {
+ capabilities: [],
+ },
+ });
+
+ const { result } = renderHook(() => useIsPlacementGroupsEnabled());
+ expect(result.current).toStrictEqual({
+ isPlacementGroupsEnabled: false,
+ });
+ });
+});
diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts
index d462862a1dd..f7de317351c 100644
--- a/packages/manager/src/features/PlacementGroups/utils.ts
+++ b/packages/manager/src/features/PlacementGroups/utils.ts
@@ -1,5 +1,8 @@
import { AFFINITY_TYPES } from '@linode/api-v4/lib/placement-groups';
+import { useFlags } from 'src/hooks/useFlags';
+import { useAccount } from 'src/queries/account/account';
+
import type {
AffinityEnforcement,
CreatePlacementGroupPayload,
@@ -118,3 +121,30 @@ export const getLinodesFromAllPlacementGroups = (
return Array.from(new Set(linodeIds));
};
+
+/**
+ * Hook to determine if the Placement Group feature should be visible to the user.
+ * Dased on the user's account capability and the feature flag.
+ *
+ * @returns {boolean} - Whether the Placement Group feature is enabled for the current user.
+ */
+export const useIsPlacementGroupsEnabled = (): {
+ isPlacementGroupsEnabled: boolean;
+} => {
+ const { data: account, error } = useAccount();
+ const flags = useFlags();
+
+ if (error || !flags) {
+ return { isPlacementGroupsEnabled: false };
+ }
+
+ const hasAccountCapability = account?.capabilities?.includes(
+ 'Placement Group'
+ );
+ const isFeatureFlagEnabled = flags.placementGroups?.enabled;
+ const isPlacementGroupsEnabled = Boolean(
+ hasAccountCapability && isFeatureFlagEnabled
+ );
+
+ return { isPlacementGroupsEnabled };
+};
diff --git a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx
index 35f703b238a..1b0b629d373 100644
--- a/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx
+++ b/packages/manager/src/features/TopMenu/AddNewMenu/AddNewMenu.tsx
@@ -27,6 +27,7 @@ import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg';
import { Button } from 'src/components/Button/Button';
import { Divider } from 'src/components/Divider';
import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils';
+import { useIsPlacementGroupsEnabled } from 'src/features/PlacementGroups/utils';
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account/account';
import { useDatabaseEnginesQuery } from 'src/queries/databases';
@@ -63,6 +64,7 @@ export const AddNewMenu = () => {
(checkRestrictedUser && !enginesLoading && !enginesError);
const { isACLBEnabled } = useIsACLBEnabled();
+ const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled();
const handleClick = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
@@ -114,7 +116,7 @@ export const AddNewMenu = () => {
{
description: "Control your Linodes' physical placement",
entity: 'Placement Groups',
- hide: !flags.placementGroups?.enabled,
+ hide: !isPlacementGroupsEnabled,
icon: PlacementGroupsIcon,
link: '/placement-groups/create',
},