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