Skip to content

Commit

Permalink
upcoming: [M3-7610] - Placement Groups Detail (#10096)
Browse files Browse the repository at this point in the history
* Initial components

* Wrap up with tests

* Fix test!

* Changeset and cleanup

* Feedback

* Add test and story

* cleanup

* feedback

* oops fix test

* Moar Feedback
  • Loading branch information
abailly-akamai authored Jan 26, 2024
1 parent f0b774e commit dfdab4b
Show file tree
Hide file tree
Showing 13 changed files with 306 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Placement Groups Detail Page ([#10096](https://github.com/linode/manager/pull/10096))
1 change: 1 addition & 0 deletions packages/manager/src/components/Breadcrumb/FinalCrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const FinalCrumb = React.memo((props: Props) => {
onCancel={onEditHandlers.onCancel}
onEdit={onEditHandlers.onEdit}
text={onEditHandlers.editableTextTitle}
textSuffix={onEditHandlers.editableTextTitleSuffix}
/>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/manager/src/components/Breadcrumb/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface LabelProps {

export interface EditableProps {
editableTextTitle: string;
editableTextTitleSuffix?: string;
errorText?: string;
onCancel: () => void;
onEdit: (value: string) => Promise<any>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export const Default: Story = {
},
};

export const WithSuffix: Story = {
args: {
onCancel: action('onCancel'),
text: 'I have a suffix',
},
render: (args) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [, setLocalArgs] = useArgs();
const onEdit = (updatedText: string) => {
return Promise.resolve(setLocalArgs({ text: updatedText }));
};

return (
<EditableText {...args} onEdit={onEdit} textSuffix=" (I am the suffix)" />
);
},
};

const meta: Meta<typeof EditableText> = {
component: EditableText,
title: 'Components/Editable Text',
Expand Down
22 changes: 22 additions & 0 deletions packages/manager/src/components/EditableText/EditableText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,26 @@ describe('Editable Text', () => {
fireEvent.click(saveButton);
expect(props.onEdit).toHaveBeenCalled();
});

it('appends a suffix to the text when provided', () => {
const { getByRole, getByTestId, getByText } = renderWithTheme(
<EditableText {...props} textSuffix=" suffix" />
);

const text = getByText('Edit this suffix');
expect(text).toBeVisible();

const editButton = getByRole('button', { name: BUTTON_LABEL });
expect(editButton).toBeInTheDocument();

fireEvent.click(editButton);
const textfield = getByTestId('textfield-input');

expect(textfield).toHaveValue('Edit this');

const closeButton = getByTestId(CLOSE_BUTTON_ICON);
fireEvent.click(closeButton);

expect(getByText('Edit this suffix')).toBeVisible();
});
});
13 changes: 12 additions & 1 deletion packages/manager/src/components/EditableText/EditableText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ interface Props {
* The text inside the textbox
*/
text: string;
/**
* Optional suffix to append to the text when it is not in editing mode
*/
textSuffix?: string;
}

type PassThroughProps = Props & Omit<TextFieldProps, 'label'>;
Expand All @@ -138,6 +142,7 @@ export const EditableText = (props: PassThroughProps) => {
onCancel,
onEdit,
text: propText,
textSuffix,
...rest
} = props;

Expand Down Expand Up @@ -192,7 +197,11 @@ export const EditableText = (props: PassThroughProps) => {
}
};
const labelText = (
<H1Header className={classes.root} data-qa-editable-text title={text} />
<H1Header
className={classes.root}
data-qa-editable-text
title={`${text}${textSuffix ?? ''}`}
/>
);

return !isEditing && !errorText ? (
Expand Down Expand Up @@ -239,13 +248,15 @@ export const EditableText = (props: PassThroughProps) => {
value={text}
/>
<Button
aria-label="Save"
className={classes.button}
data-qa-save-edit
onClick={finishEditing}
>
<Check className={classes.icon} />
</Button>
<Button
aria-label="Cancel"
className={classes.button}
data-qa-cancel-edit
onClick={cancelEditing}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as React from 'react';

import { placementGroupFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { PlacementGroupsDetail } from './PlacementGroupsDetail';

const queryMocks = vi.hoisted(() => ({
usePlacementGroupQuery: vi.fn().mockReturnValue({}),
}));

vi.mock('src/queries/placementGroups', async () => {
const actual = await vi.importActual('src/queries/placementGroups');
return {
...actual,
usePlacementGroupQuery: queryMocks.usePlacementGroupQuery,
};
});

describe('PlacementGroupsLanding', () => {
it('renders a error page', () => {
const { getByText } = renderWithTheme(<PlacementGroupsDetail />);

expect(getByText('Not Found')).toBeInTheDocument();
});

it('renders a loading state', () => {
queryMocks.usePlacementGroupQuery.mockReturnValue({
data: {
data: placementGroupFactory.build({
id: 1,
}),
},
isLoading: true,
});

const { getByRole } = renderWithTheme(<PlacementGroupsDetail />, {
MemoryRouter: {
initialEntries: [{ pathname: '/placement-groups/1' }],
},
});

expect(getByRole('progressbar')).toBeInTheDocument();
});

it('renders breadcrumbs, docs link and tabs', () => {
queryMocks.usePlacementGroupQuery.mockReturnValue({
data: placementGroupFactory.build({
affinity_type: 'anti_affinity',
id: 1,
label: 'My first PG',
}),
});

const { getByRole, getByText } = renderWithTheme(
<PlacementGroupsDetail />,
{
MemoryRouter: {
initialEntries: [{ pathname: '/placement-groups/1' }],
},
}
);

expect(getByText(/my first pg \(Anti-affinity\)/i)).toBeInTheDocument();
expect(getByText(/docs/i)).toBeInTheDocument();
expect(getByRole('tab', { name: 'Summary' })).toBeInTheDocument();
expect(getByRole('tab', { name: 'Linodes (3)' })).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as React from 'react';
import { useHistory, useParams } from 'react-router-dom';

import { CircleProgress } from 'src/components/CircleProgress';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { NotFound } from 'src/components/NotFound';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabLinkList } from 'src/components/Tabs/TabLinkList';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
import { useFlags } from 'src/hooks/useFlags';
import {
useMutatePlacementGroup,
usePlacementGroupQuery,
} from 'src/queries/placementGroups';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';

import { getAffinityLabel, getPlacementGroupLinodeCount } from '../utils';

export const PlacementGroupsDetail = () => {
const flags = useFlags();
const { id, tab } = useParams<{ id: string; tab?: string }>();
const history = useHistory();
const placementGroupId = Number(id);
const {
data: placementGroup,
error: placementGroupError,
isLoading,
} = usePlacementGroupQuery(placementGroupId, Boolean(flags.vmPlacement));
const {
error: updatePlacementGroupError,
mutateAsync: updatePlacementGroup,
reset,
} = useMutatePlacementGroup(placementGroupId);
const errorText = getErrorStringOrDefault(updatePlacementGroupError ?? '');

if (!placementGroup) {
return <NotFound />;
}

if (placementGroupError) {
return (
<ErrorState errorText="There was a problem retrieving your Placement Group. Please try again." />
);
}

if (isLoading) {
return <CircleProgress />;
}

const linodeCount = getPlacementGroupLinodeCount(placementGroup);
const tabs = [
{
routeName: `/placement-groups/${id}`,
title: 'Summary',
},
{
routeName: `/placement-groups/${id}/linodes`,
title: `Linodes (${linodeCount})`,
},
];
const { affinity_type, label } = placementGroup;
const affinityLabel = getAffinityLabel(affinity_type);
const tabIndex = tab ? tabs.findIndex((t) => t.routeName.endsWith(tab)) : -1;

const resetEditableLabel = () => {
return `${label} (${affinityLabel})`;
};

const handleLabelEdit = (newLabel: string) => {
if (updatePlacementGroupError) {
reset();
}

return updatePlacementGroup({ label: newLabel });
};

return (
<>
<DocumentTitleSegment segment={label} />
<LandingHeader
breadcrumbProps={{
crumbOverrides: [
{
label: 'Placement Groups',
position: 1,
},
],
onEditHandlers: {
editableTextTitle: label,
editableTextTitleSuffix: ` (${affinityLabel})`,
errorText,
onCancel: resetEditableLabel,
onEdit: handleLabelEdit,
},
pathname: `/placement-groups/${label}`,
}}
docsLabel="Docs"
docsLink="TODO VM_Placement: add doc link"
title="Placement Group Detail"
/>
<Tabs
index={tabIndex === -1 ? 0 : tabIndex}
onChange={(i) => history.push(tabs[i].routeName)}
>
<TabLinkList tabs={tabs} />

<TabPanels>
<SafeTabPanel index={0}>TODO VM_Placement: summary</SafeTabPanel>
<SafeTabPanel index={1}>TODO VM_Placement: linode list</SafeTabPanel>
</TabPanels>
</Tabs>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Typography } from 'src/components/Typography';
import { useLinodesQuery } from 'src/queries/linodes/linodes';
import { useRegionsQuery } from 'src/queries/regions';

import { getAffinityLabel, getPlacementGroupLinodeCount } from '../utils';
import { StyledWarningIcon } from './PlacementGroupsRow.styles';

import type { PlacementGroup } from '@linode/api-v4';
Expand Down Expand Up @@ -42,12 +43,12 @@ export const PlacementGroupsRow = React.memo(
const regionLabel =
regions?.find((region) => region.id === placementGroup.region)?.label ??
placementGroup.region;
const numberOfAssignedLinodesAsString = linode_ids.length.toString() ?? '';
const linodeCount = getPlacementGroupLinodeCount(placementGroup);
const listOfAssignedLinodes = linodes?.data.filter((linode) =>
linode_ids.includes(linode.id)
);
const affinityType =
affinity_type === 'affinity' ? 'Affinity' : 'Anti-affinity';
const affinityLabel = getAffinityLabel(affinity_type);

const actions: Action[] = [
{
onClick: handleRenamePlacementGroup,
Expand All @@ -67,9 +68,10 @@ export const PlacementGroupsRow = React.memo(
<TableCell>
<Link
data-testid={`link-to-placement-group-${id}`}
style={{ marginRight: 8 }}
to={`/placement-groups/${id}`}
>
{label} ({affinityType}) &nbsp;
{label} ({affinityLabel})
</Link>
{!compliant && (
<Typography component="span" sx={{ whiteSpace: 'nowrap' }}>
Expand All @@ -87,7 +89,7 @@ export const PlacementGroupsRow = React.memo(
))}
</List>
}
displayText={numberOfAssignedLinodesAsString}
displayText={`${linodeCount}`}
minWidth={200}
/>
&nbsp; of {limits}
Expand Down
16 changes: 6 additions & 10 deletions packages/manager/src/features/PlacementGroups/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ const PlacementGroupsLanding = React.lazy(() =>
}))
);

// TODO VM_Placement: add <PlacementGroupsDetail />
// const PlacementGroupDetail = React.lazy(() =>
// import('./PlacementGroupDetail/PlacementGroupDetail').then((module) => ({
// default: module.PlacementGroupsDetail,
// }))
// );
const PlacementGroupsDetail = React.lazy(() =>
import('./PlacementGroupsDetail/PlacementGroupsDetail').then((module) => ({
default: module.PlacementGroupsDetail,
}))
);

export const PlacementGroups = () => {
const { path } = useRouteMatch();
Expand All @@ -32,10 +31,7 @@ export const PlacementGroups = () => {
exact
path={`${path}/create`}
/>
{/*
// TODO VM_Placement: add <PlacementGroupsDetail />
<Route component={FirewallDetail} path={`${path}/:id/:tab?`} />
*/}
<Route component={PlacementGroupsDetail} path={`${path}/:id/:tab?`} />
<Route component={PlacementGroupsLanding} />
</Switch>
</React.Fragment>
Expand Down
Loading

0 comments on commit dfdab4b

Please sign in to comment.