From 9582ca1021e809674531c51066a9f768bf8c5162 Mon Sep 17 00:00:00 2001
From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com>
Date: Fri, 19 Jan 2024 09:09:52 -0500
Subject: [PATCH] upcoming: [M3-7609] - Placement Groups Landing Page (#10068)
* Initial commit - menu entry, routes and skeletons
* Saving progress: table rows
* Saving progress: Table data
* Tests and cleanup
* moar cleanup
* Compliance col remove, styling and cleanup
* Cleanup and changeset
* Fix unit test
* Update icon and limits
* Feedback
* Moar Feedback
---
packages/api-v4/src/placement-groups/types.ts | 3 +-
...r-10068-upcoming-features-1705596672088.md | 5 +
packages/manager/src/MainContent.tsx | 9 +
.../icons/entityIcons/placement-groups.svg | 5 +
.../PrimaryNav/PrimaryNav.styles.ts | 5 +
.../src/components/PrimaryNav/PrimaryNav.tsx | 11 +
.../manager/src/factories/placementGroups.ts | 17 +-
packages/manager/src/featureFlags.ts | 1 +
.../PlacementGroupsLanding.test.tsx | 75 +++++++
.../PlacementGroupsLanding.tsx | 192 ++++++++++++++++++
.../PlacementGroupsRow.styles.ts | 13 ++
.../PlacementGroupsRow.test.tsx | 95 +++++++++
.../PlacementGroupsRow.tsx | 110 ++++++++++
.../src/features/PlacementGroups/constants.ts | 1 +
.../src/features/PlacementGroups/index.tsx | 44 ++++
15 files changed, 580 insertions(+), 6 deletions(-)
create mode 100644 packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md
create mode 100644 packages/manager/src/assets/icons/entityIcons/placement-groups.svg
create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx
create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx
create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts
create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx
create mode 100644 packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx
create mode 100644 packages/manager/src/features/PlacementGroups/constants.ts
create mode 100644 packages/manager/src/features/PlacementGroups/index.tsx
diff --git a/packages/api-v4/src/placement-groups/types.ts b/packages/api-v4/src/placement-groups/types.ts
index 7dc96bfcfa1..ced0f1fe430 100644
--- a/packages/api-v4/src/placement-groups/types.ts
+++ b/packages/api-v4/src/placement-groups/types.ts
@@ -1,6 +1,6 @@
import type { Region } from '../regions/types';
-export type AffinityType = 'affinity' | 'anti-affinity';
+export type AffinityType = 'affinity' | 'anti_affinity';
export interface PlacementGroup {
id: number;
@@ -9,6 +9,7 @@ export interface PlacementGroup {
affinity_type: AffinityType;
compliant: boolean;
linode_ids: number[];
+ limits: number;
}
export type CreatePlacementGroupPayload = Pick<
diff --git a/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md b/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md
new file mode 100644
index 00000000000..f874e3cdbbc
--- /dev/null
+++ b/packages/manager/.changeset/pr-10068-upcoming-features-1705596672088.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Placement Groups Landing Page ([#10068](https://github.com/linode/manager/pull/10068))
diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx
index c0594e97b78..5e4e80003ff 100644
--- a/packages/manager/src/MainContent.tsx
+++ b/packages/manager/src/MainContent.tsx
@@ -169,6 +169,11 @@ const Firewalls = React.lazy(() => import('src/features/Firewalls'));
const Databases = React.lazy(() => import('src/features/Databases'));
const BetaRoutes = React.lazy(() => import('src/features/Betas'));
const VPC = React.lazy(() => import('src/features/VPCs'));
+const PlacementGroups = React.lazy(() =>
+ import('src/features/PlacementGroups').then((module) => ({
+ default: module.PlacementGroups,
+ }))
+);
export const MainContent = () => {
const { classes, cx } = useStyles();
@@ -325,6 +330,10 @@ export const MainContent = () => {
}>
+
{flags.aglb && (
diff --git a/packages/manager/src/assets/icons/entityIcons/placement-groups.svg b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg
new file mode 100644
index 00000000000..044e800bfb5
--- /dev/null
+++ b/packages/manager/src/assets/icons/entityIcons/placement-groups.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts
index c5aaac9ca31..f37b8be6330 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts
@@ -21,6 +21,11 @@ const useStyles = makeStyles()(
left: 70,
position: 'absolute',
},
+ '&.beta-chip-placement-groups': {
+ bottom: -2,
+ left: 52,
+ position: 'absolute',
+ },
marginTop: 2,
},
divider: {
diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
index 354c427f634..8061c923d5a 100644
--- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
+++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
@@ -14,6 +14,7 @@ import Linode from 'src/assets/icons/entityIcons/linode.svg';
import Managed from 'src/assets/icons/entityIcons/managed.svg';
import NodeBalancer from 'src/assets/icons/entityIcons/nodebalancer.svg';
import OCA from 'src/assets/icons/entityIcons/oneclick.svg';
+import PlacementGroups from 'src/assets/icons/entityIcons/placement-groups.svg';
import StackScript from 'src/assets/icons/entityIcons/stackscript.svg';
import Volume from 'src/assets/icons/entityIcons/volume.svg';
import VPC from 'src/assets/icons/entityIcons/vpc.svg';
@@ -54,6 +55,7 @@ type NavEntity =
| 'Marketplace'
| 'NodeBalancers'
| 'Object Storage'
+ | 'Placement Groups'
| 'StackScripts'
| 'VPC'
| 'Volumes';
@@ -171,6 +173,14 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
href: '/linodes',
icon: ,
},
+ {
+ betaChipClassName: 'beta-chip-placement-groups',
+ display: 'Placement Groups',
+ hide: !flags.vmPlacement,
+ href: '/placement-groups',
+ icon: ,
+ isBeta: true,
+ },
{
display: 'Volumes',
href: '/volumes',
@@ -288,6 +298,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
allowMarketplacePrefetch,
flags.databaseBeta,
flags.aglb,
+ flags.vmPlacement,
showVPCs,
]
);
diff --git a/packages/manager/src/factories/placementGroups.ts b/packages/manager/src/factories/placementGroups.ts
index 0bba16d0a33..39805bd8246 100644
--- a/packages/manager/src/factories/placementGroups.ts
+++ b/packages/manager/src/factories/placementGroups.ts
@@ -8,17 +8,24 @@ import type {
} from '@linode/api-v4';
export const placementGroupFactory = Factory.Sync.makeFactory({
- affinity_type: 'anti-affinity',
- compliant: true,
+ affinity_type: Factory.each(() => pickRandom(['affinity', 'anti_affinity'])),
+ compliant: Factory.each(() => pickRandom([true, false])),
id: Factory.each((id) => id),
label: Factory.each((id) => `pg-${id}`),
- linode_ids: [1, 2, 3],
- region: pickRandom(['us-east', 'us-southeast', 'ca-central']),
+ limits: 10,
+ linode_ids: Factory.each(() => [
+ pickRandom([1, 2, 3]),
+ pickRandom([4, 5, 6]),
+ pickRandom([7, 8, 9]),
+ ]),
+ region: Factory.each(() =>
+ pickRandom(['us-east', 'us-southeast', 'ca-central'])
+ ),
});
export const createPlacementGroupPayloadFactory = Factory.Sync.makeFactory(
{
- affinity_type: 'anti-affinity',
+ affinity_type: 'anti_affinity',
label: Factory.each((id) => `mock-pg-${id}`),
region: pickRandom(['us-east', 'us-southeast', 'ca-central']),
}
diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts
index dfc360f9bf0..70a6b1ea4cf 100644
--- a/packages/manager/src/featureFlags.ts
+++ b/packages/manager/src/featureFlags.ts
@@ -143,6 +143,7 @@ export type ProductInformationBannerLocation =
| 'Managed'
| 'NodeBalancers'
| 'Object Storage'
+ | 'Placement Groups'
| 'StackScripts'
| 'VPC'
| 'Volumes';
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx
new file mode 100644
index 00000000000..c8c69eb7ec5
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.test.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+
+import { placementGroupFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { PlacementGroupsLanding } from './PlacementGroupsLanding';
+
+const queryMocks = vi.hoisted(() => ({
+ usePlacementGroupsQuery: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/placementGroups', async () => {
+ const actual = await vi.importActual('src/queries/placementGroups');
+ return {
+ ...actual,
+ usePlacementGroupsQuery: queryMocks.usePlacementGroupsQuery,
+ };
+});
+
+describe('PlacementGroupsLanding', () => {
+ it('renders loading state', () => {
+ queryMocks.usePlacementGroupsQuery.mockReturnValue({
+ isLoading: true,
+ });
+
+ const { getByRole } = renderWithTheme();
+
+ expect(getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('renders error state', () => {
+ queryMocks.usePlacementGroupsQuery.mockReturnValue({
+ error: [{ reason: 'Not found' }],
+ });
+
+ const { getByText } = renderWithTheme();
+
+ expect(getByText(/not found/i)).toBeInTheDocument();
+ });
+
+ it('renders docs link and create button', () => {
+ queryMocks.usePlacementGroupsQuery.mockReturnValue({
+ data: {
+ data: [],
+ results: 0,
+ },
+ });
+
+ const { getByText } = renderWithTheme();
+
+ expect(getByText(/create placement group/i)).toBeInTheDocument();
+ expect(getByText(/docs/i)).toBeInTheDocument();
+ });
+
+ it('renders placement groups', () => {
+ queryMocks.usePlacementGroupsQuery.mockReturnValue({
+ data: {
+ data: [
+ placementGroupFactory.build({
+ label: 'group 1',
+ }),
+ placementGroupFactory.build({
+ label: 'group 2',
+ }),
+ ],
+ results: 2,
+ },
+ });
+
+ const { getByText } = renderWithTheme();
+
+ expect(getByText(/group 1/i)).toBeInTheDocument();
+ expect(getByText(/group 2/i)).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx
new file mode 100644
index 00000000000..fa3a6d542dd
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx
@@ -0,0 +1,192 @@
+import CloseIcon from '@mui/icons-material/Close';
+import * as React from 'react';
+import { useHistory } from 'react-router-dom';
+
+import { CircleProgress } from 'src/components/CircleProgress';
+import { ErrorState } from 'src/components/ErrorState/ErrorState';
+import { Hidden } from 'src/components/Hidden';
+import { IconButton } from 'src/components/IconButton';
+import { InputAdornment } from 'src/components/InputAdornment';
+import { LandingHeader } from 'src/components/LandingHeader';
+import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
+import { Table } from 'src/components/Table';
+import { TableBody } from 'src/components/TableBody';
+import { TableCell } from 'src/components/TableCell';
+import { TableHead } from 'src/components/TableHead';
+import { TableRow } from 'src/components/TableRow';
+import { TableSortCell } from 'src/components/TableSortCell/TableSortCell';
+import { TextField } from 'src/components/TextField';
+import { Typography } from 'src/components/Typography';
+import { useOrder } from 'src/hooks/useOrder';
+import { usePagination } from 'src/hooks/usePagination';
+import { usePlacementGroupsQuery } from 'src/queries/placementGroups';
+import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+
+import { MAX_NUMBER_OF_PLACEMENT_GROUPS } from '../constants';
+import { PlacementGroupsRow } from './PlacementGroupsRow';
+
+import type { PlacementGroup } from '@linode/api-v4';
+
+const preferenceKey = 'placement-groups';
+
+export const PlacementGroupsLanding = React.memo(() => {
+ const history = useHistory();
+ const pagination = usePagination(1, preferenceKey);
+ const [_selectedPlacementGroup, setSelectedPlacementGroup] = React.useState<
+ PlacementGroup | undefined
+ >();
+ const [query, setQuery] = React.useState('');
+ const { handleOrderChange, order, orderBy } = useOrder(
+ {
+ order: 'asc',
+ orderBy: 'label',
+ },
+ `${preferenceKey}-order`
+ );
+
+ const filter = {
+ ['+order']: order,
+ ['+order_by']: orderBy,
+ };
+
+ if (query) {
+ filter['label'] = { '+contains': query };
+ }
+
+ const params = {
+ page: pagination.page,
+ page_size: pagination.pageSize,
+ };
+
+ const { data: placementGroups, error, isLoading } = usePlacementGroupsQuery(
+ params,
+ filter
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ // if (placementGroups?.results === 0) {
+ // return {
+ // /* TODO VM_Placement: add */
+ // };
+ // }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ const onOpenCreateDrawer = () => {
+ history.push('/placement-groups/create');
+ };
+
+ const handleRenamePlacementGroup = (placementGroup: PlacementGroup) => {
+ setSelectedPlacementGroup(placementGroup);
+ };
+
+ const handleDeletePlacementGroup = (placementGroup: PlacementGroup) => {
+ setSelectedPlacementGroup(placementGroup);
+ };
+
+ return (
+ <>
+ = MAX_NUMBER_OF_PLACEMENT_GROUPS) ||
+ false,
+ }}
+ breadcrumbProps={{ pathname: '/placement-groups' }}
+ docsLink={'TODO VM_Placement: add doc link'}
+ entity="Placement Group"
+ onButtonClick={onOpenCreateDrawer}
+ title="Placement Groups"
+ />
+
+ The maximum amount of Placement Groups is{' '}
+ {MAX_NUMBER_OF_PLACEMENT_GROUPS} per account.
+
+
+ setQuery('')}
+ size="small"
+ sx={{ padding: 'unset' }}
+ >
+
+
+
+ ),
+ }}
+ hideLabel
+ label="Filter"
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="Filter"
+ sx={{ mb: 4 }}
+ value={query}
+ />
+
+
+
+
+ Label
+
+ Linodes
+
+
+ Region
+
+
+
+
+
+
+ {placementGroups?.data.map((placementGroup) => (
+
+ handleDeletePlacementGroup(placementGroup)
+ }
+ handleRenamePlacementGroup={() =>
+ handleRenamePlacementGroup(placementGroup)
+ }
+ key={`pg-${placementGroup.id}`}
+ placementGroup={placementGroup}
+ />
+ ))}
+
+
+
+ {/* TODO VM_Placement: add delete dialog */}
+ {/* TODO VM_Placement: add create/edit drawer */}
+ >
+ );
+});
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts
new file mode 100644
index 00000000000..af117412178
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.styles.ts
@@ -0,0 +1,13 @@
+import Warning from '@mui/icons-material/Warning';
+import { styled } from '@mui/material/styles';
+
+export const StyledWarningIcon = styled(Warning, {
+ label: 'StyledWarningIcon',
+})(({ theme }) => ({
+ fill: theme.color.yellow,
+ height: 16,
+ marginRight: theme.spacing(),
+ position: 'relative',
+ top: 2,
+ width: 16,
+}));
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx
new file mode 100644
index 00000000000..9c88e3cf6af
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.test.tsx
@@ -0,0 +1,95 @@
+import * as React from 'react';
+
+import { linodeFactory } from 'src/factories';
+import { regionFactory } from 'src/factories';
+import { placementGroupFactory } from 'src/factories';
+import {
+ renderWithTheme,
+ resizeScreenSize,
+ wrapWithTableBody,
+} from 'src/utilities/testHelpers';
+
+import { PlacementGroupsRow } from './PlacementGroupsRow';
+
+const queryMocks = vi.hoisted(() => ({
+ useLinodesQuery: vi.fn().mockReturnValue({}),
+ useRegionsQuery: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('src/queries/linodes/linodes', async () => {
+ const actual = await vi.importActual('src/queries/linodes/linodes');
+
+ return {
+ ...actual,
+ useLinodesQuery: queryMocks.useLinodesQuery,
+ };
+});
+
+vi.mock('src/queries/regions', async () => {
+ const actual = await vi.importActual('src/queries/regions');
+
+ return {
+ ...actual,
+ useRegionsQuery: queryMocks.useRegionsQuery,
+ };
+});
+
+const handleDeletePlacementGroupMock = vi.fn();
+const handleRenamePlacementGroupMock = vi.fn();
+
+describe('PlacementGroupsLanding', () => {
+ it('renders the columns with proper data', () => {
+ resizeScreenSize(1200);
+
+ queryMocks.useLinodesQuery.mockReturnValue({
+ data: {
+ data: [
+ linodeFactory.build({
+ id: 1,
+ label: 'linode1',
+ region: 'us-east',
+ }),
+ ],
+ results: 0,
+ },
+ });
+
+ queryMocks.useRegionsQuery.mockReturnValue({
+ data: [
+ regionFactory.build({
+ country: 'us',
+ id: 'us-east',
+ label: 'Newark, NJ',
+ status: 'ok',
+ }),
+ ],
+ });
+
+ const { getByRole, getByTestId, getByText } = renderWithTheme(
+ wrapWithTableBody(
+
+ )
+ );
+
+ expect(getByTestId('link-to-placement-group-1')).toHaveTextContent(
+ 'group 1 (Anti-affinity)'
+ );
+ expect(getByText('Non-compliant')).toBeInTheDocument();
+ expect(getByTestId('placement-group-1-assigned-linodes')).toHaveTextContent(
+ '1'
+ );
+ expect(getByText('Newark, NJ')).toBeInTheDocument();
+ expect(getByRole('button', { name: 'Rename' })).toBeInTheDocument();
+ expect(getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx
new file mode 100644
index 00000000000..5f5c75617e9
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { Hidden } from 'src/components/Hidden';
+import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
+import { List } from 'src/components/List';
+import { ListItem } from 'src/components/ListItem';
+import { TableCell } from 'src/components/TableCell';
+import { TableRow } from 'src/components/TableRow';
+import { TextTooltip } from 'src/components/TextTooltip';
+import { Typography } from 'src/components/Typography';
+import { useLinodesQuery } from 'src/queries/linodes/linodes';
+import { useRegionsQuery } from 'src/queries/regions';
+
+import { StyledWarningIcon } from './PlacementGroupsRow.styles';
+
+import type { PlacementGroup } from '@linode/api-v4';
+import type { Action } from 'src/components/ActionMenu/ActionMenu';
+
+interface PlacementGroupsRowProps {
+ handleDeletePlacementGroup: () => void;
+ handleRenamePlacementGroup: () => void;
+ placementGroup: PlacementGroup;
+}
+
+export const PlacementGroupsRow = React.memo(
+ ({
+ handleDeletePlacementGroup,
+ handleRenamePlacementGroup,
+ placementGroup,
+ }: PlacementGroupsRowProps) => {
+ const {
+ affinity_type,
+ compliant,
+ id,
+ label,
+ limits,
+ linode_ids,
+ } = placementGroup;
+ const { data: regions } = useRegionsQuery();
+ const { data: linodes } = useLinodesQuery();
+ const regionLabel =
+ regions?.find((region) => region.id === placementGroup.region)?.label ??
+ placementGroup.region;
+ const numberOfAssignedLinodesAsString = linode_ids.length.toString() ?? '';
+ const listOfAssignedLinodes = linodes?.data.filter((linode) =>
+ linode_ids.includes(linode.id)
+ );
+ const affinityType =
+ affinity_type === 'affinity' ? 'Affinity' : 'Anti-affinity';
+ const actions: Action[] = [
+ {
+ onClick: handleRenamePlacementGroup,
+ title: 'Rename',
+ },
+ {
+ onClick: handleDeletePlacementGroup,
+ title: 'Delete',
+ },
+ ];
+
+ return (
+
+
+
+ {label} ({affinityType})
+
+ {!compliant && (
+
+
+ Non-compliant
+
+ )}
+
+
+
+ {listOfAssignedLinodes?.map((linode, idx) => (
+ {linode.label}
+ ))}
+
+ }
+ displayText={numberOfAssignedLinodesAsString}
+ minWidth={200}
+ />
+ of {limits}
+
+
+ {regionLabel}
+
+
+ {actions.map((action) => (
+
+ ))}
+
+
+ );
+ }
+);
diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts
new file mode 100644
index 00000000000..156376c24eb
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/constants.ts
@@ -0,0 +1 @@
+export const MAX_NUMBER_OF_PLACEMENT_GROUPS = 5;
diff --git a/packages/manager/src/features/PlacementGroups/index.tsx b/packages/manager/src/features/PlacementGroups/index.tsx
new file mode 100644
index 00000000000..5328452cebb
--- /dev/null
+++ b/packages/manager/src/features/PlacementGroups/index.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+import { Route, Switch, useRouteMatch } from 'react-router-dom';
+
+import { DocumentTitleSegment } from 'src/components/DocumentTitle';
+import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner';
+import { SuspenseLoader } from 'src/components/SuspenseLoader';
+
+const PlacementGroupsLanding = React.lazy(() =>
+ import('./PlacementGroupsLanding/PlacementGroupsLanding').then((module) => ({
+ default: module.PlacementGroupsLanding,
+ }))
+);
+
+// TODO VM_Placement: add
+// const PlacementGroupDetail = React.lazy(() =>
+// import('./PlacementGroupDetail/PlacementGroupDetail').then((module) => ({
+// default: module.PlacementGroupsDetail,
+// }))
+// );
+
+export const PlacementGroups = () => {
+ const { path } = useRouteMatch();
+
+ return (
+ }>
+
+
+
+
+
+ {/*
+ // TODO VM_Placement: add
+
+ */}
+
+
+
+
+ );
+};