Skip to content

Commit

Permalink
upcoming: [M3-7875] - Linode Create Refactor - Backups (linode#10404)
Browse files Browse the repository at this point in the history
* initial backups ui

* more work

* more work

* more work and start adding testing

* allow backup deep link

* make behavior match existing flow

* more progress

* add no Linode selected message

* unit test, fix bugs, and improve parity

* make styles match existing flow

* hide region panel for backups

* clean up and filter table on preselection

* move the selected linode to form state (i hope i don't regret this)

* improve glitchy rendering

* fix breaking changes

* add changesets

* add comment

* improve unit testing

* clean up and fixes

* use an MUI `<List />`

* fix type and use constant for the default distribution

* clean up no backups logic

* make date time format match existing flow

* make grid margin match existing flow by wrapping grids in a non-flex container

* feedback from @hkhalil-akamai

---------

Co-authored-by: Banks Nussman <[email protected]>
  • Loading branch information
bnussman-akamai and bnussman authored Apr 29, 2024
1 parent 13b858c commit 0f593e0
Show file tree
Hide file tree
Showing 18 changed files with 811 additions and 32 deletions.
5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-10404-changed-1714064775076.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Changed
---

Allow `backup_id` to be `null` in `CreateLinodeRequest` ([#10404](https://github.com/linode/manager/pull/10404))
2 changes: 1 addition & 1 deletion packages/api-v4/src/linodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ export interface CreateLinodeRequest {
*
* This field and the image field are mutually exclusive.
*/
backup_id?: number;
backup_id?: number | null;
/**
* When deploying from an Image, this field is optional, otherwise it is ignored.
* This is used to set the swap disk size for the newly-created Linode.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Linode Create Refactor - Backups ([#10404](https://github.com/linode/manager/pull/10404))
6 changes: 4 additions & 2 deletions packages/manager/src/features/Linodes/LinodeCreatev2/Plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { useAllTypes } from 'src/queries/types';
import { sendLinodeCreateFlowDocsClickEvent } from 'src/utilities/analytics';
import { extendType } from 'src/utilities/extendType';

import type { LinodeCreateFormValues } from './utilities';
import type { CreateLinodeRequest } from '@linode/api-v4';

export const Plan = () => {
const regionId = useWatch<CreateLinodeRequest>({ name: 'region' });
const regionId = useWatch<CreateLinodeRequest, 'region'>({ name: 'region' });
const linode = useWatch<LinodeCreateFormValues, 'linode'>({ name: 'linode' });

const { field, fieldState } = useController<CreateLinodeRequest>({
name: 'type',
Expand Down Expand Up @@ -40,7 +42,7 @@ export const Plan = () => {
disabled={isLinodeCreateRestricted}
error={fieldState.error?.message}
isCreate
linodeID={undefined} // @todo add cloning support
linodeID={linode?.id}
onSelect={field.onChange}
regionsData={regions} // @todo move this query deeper if possible
selectedId={field.value}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';

import { backupFactory, linodeFactory } from 'src/factories';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';

import { BackupSelect } from './BackupSelect';

import type { LinodeCreateFormValues } from '../../utilities';
import type { LinodeBackupsResponse } from '@linode/api-v4';

describe('BackupSelect', () => {
it('renders a heading', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: <BackupSelect />,
});

const heading = getByText('Select Backup');

expect(heading).toBeVisible();
expect(heading.tagName).toBe('H2');
});

it('should render backups based on the selected Linode ID in form state', async () => {
const backups: LinodeBackupsResponse = {
automatic: backupFactory.buildList(3),
snapshot: {
current: backupFactory.build({ label: 'my-snapshot' }),
in_progress: null,
},
};

server.use(
http.get('*/linode/instances/2/backups', () => {
return HttpResponse.json(backups);
})
);

const {
findAllByText,
getByText,
} = renderWithThemeAndHookFormContext<LinodeCreateFormValues>({
component: <BackupSelect />,
useFormOptions: {
defaultValues: { linode: linodeFactory.build({ id: 2 }) },
},
});

const automaticBackups = await findAllByText('Automatic');

// Verify all 3 automatic backups show up
expect(automaticBackups).toHaveLength(3);

// Verify the latest snapshot shows up
expect(getByText('my-snapshot')).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Grid from '@mui/material/Unstable_Grid2';
import React from 'react';
import { useController, useWatch } from 'react-hook-form';

import { Box } from 'src/components/Box';
import { CircularProgress } from 'src/components/CircularProgress';
import { DateTimeDisplay } from 'src/components/DateTimeDisplay';
import { Notice } from 'src/components/Notice/Notice';
import { Paper } from 'src/components/Paper';
import { SelectionCard } from 'src/components/SelectionCard/SelectionCard';
import { Stack } from 'src/components/Stack';
import { Typography } from 'src/components/Typography';
import { useLinodeBackupsQuery } from 'src/queries/linodes/backups';

import { LinodeCreateFormValues } from '../../utilities';

import type { CreateLinodeRequest } from '@linode/api-v4';

export const BackupSelect = () => {
const { field, fieldState } = useController<CreateLinodeRequest, 'backup_id'>(
{ name: 'backup_id' }
);

const linode = useWatch<LinodeCreateFormValues, 'linode'>({ name: 'linode' });

const hasSelectedLinode = Boolean(linode);

const { data, isFetching } = useLinodeBackupsQuery(
linode?.id ?? -1,
hasSelectedLinode
);

const hasNoBackups = data?.automatic.length === 0 && !data.snapshot.current;

const renderContent = () => {
if (!hasSelectedLinode) {
return <Typography>First, select a Linode</Typography>;
}

if (isFetching) {
return (
<Stack alignItems="center">
<CircularProgress />
</Stack>
);
}

if (hasNoBackups) {
return <Typography>This Linode does not have any backups.</Typography>;
}

return (
<Box>
<Grid container spacing={2}>
{data?.automatic.map((backup) => (
<SelectionCard
subheadings={[
<DateTimeDisplay
key={`backup-${backup.id}-date`}
value={backup.created}
/>,
]}
checked={backup.id === field.value}
heading="Automatic"
key={backup.id}
onClick={() => field.onChange(backup.id)}
/>
))}
{data?.snapshot.current && (
<SelectionCard
subheadings={[
<DateTimeDisplay
key={`backup-${data.snapshot.current.id}-date`}
value={data.snapshot.current.created}
/>,
]}
checked={data.snapshot.current.id === field.value}
heading={data.snapshot.current.label ?? 'Snapshot'}
key={data.snapshot.current.id}
onClick={() => field.onChange(data.snapshot.current!.id)}
/>
)}
</Grid>
</Box>
);
};

return (
<Paper>
<Stack spacing={2}>
<Typography variant="h2">Select Backup</Typography>
{fieldState.error?.message && (
<Notice text={fieldState.error.message} variant="error" />
)}
{renderContent()}
</Stack>
</Paper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from 'react';

import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers';

import { Backups } from './Backups';

describe('Backups', () => {
it('renders a Linode Select section', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: <Backups />,
});

const heading = getByText('Select Linode');

expect(heading).toBeVisible();
expect(heading.tagName).toBe('H2');
});

it('renders a Backup Select section', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: <Backups />,
});

const heading = getByText('Select Backup');

expect(heading).toBeVisible();
expect(heading.tagName).toBe('H2');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';

import { Stack } from 'src/components/Stack';

import { BackupSelect } from './BackupSelect';
import { LinodeSelect } from './LinodeSelect';

export const Backups = () => {
return (
<Stack spacing={3}>
<LinodeSelect />
<BackupSelect />
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ListItem } from '@mui/material';
import React from 'react';

import { List } from 'src/components/List';
import { Notice } from 'src/components/Notice/Notice';

export const BackupsWarning = () => {
return (
<Notice variant="warning">
<List sx={{ listStyleType: 'disc', pl: 2.5 }}>
<ListItem sx={{ display: 'list-item', pl: 1, py: 0.5 }}>
This newly created Linode will be created with the same password and
SSH Keys (if any) as the original Linode.
</ListItem>
<ListItem sx={{ display: 'list-item', pl: 1, py: 0.5 }}>
This Linode will need to be manually booted after it finishes
provisioning.
</ListItem>
</List>
</Notice>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';

import { linodeFactory } from 'src/factories';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import {
mockMatchMedia,
renderWithThemeAndHookFormContext,
} from 'src/utilities/testHelpers';

import { LinodeSelect } from './LinodeSelect';

beforeAll(() => mockMatchMedia());

describe('LinodeSelect', () => {
it('should render a heading', () => {
const { getByText } = renderWithThemeAndHookFormContext({
component: <LinodeSelect />,
});

const heading = getByText('Select Linode');

expect(heading).toBeVisible();
expect(heading.tagName).toBe('H2');
});

it('should render Linodes from the API', async () => {
const linodes = linodeFactory.buildList(10);

server.use(
http.get('*/linode/instances*', () => {
return HttpResponse.json(makeResourcePage(linodes));
})
);

const { findByText } = renderWithThemeAndHookFormContext({
component: <LinodeSelect />,
});

for (const linode of linodes) {
// eslint-disable-next-line no-await-in-loop
await findByText(linode.label);
}
});

it('should select a linode based on form state', async () => {
const selectedLinode = linodeFactory.build({
id: 1,
label: 'my-selected-linode',
});

server.use(
http.get('*/linode/instances*', () => {
return HttpResponse.json(makeResourcePage([selectedLinode]));
})
);

const { findByLabelText } = renderWithThemeAndHookFormContext({
component: <LinodeSelect />,
useFormOptions: {
defaultValues: { linode: selectedLinode },
},
});

const radio = await findByLabelText(selectedLinode.label);

expect(radio).toBeEnabled();
expect(radio).toBeChecked();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

import { Paper } from 'src/components/Paper';
import { Stack } from 'src/components/Stack';
import { Typography } from 'src/components/Typography';

import { BackupsWarning } from './BackupsWarning';
import { LinodeSelectTable } from './LinodeSelectTable';

export const LinodeSelect = () => {
return (
<Paper>
<Stack spacing={1}>
<Typography variant="h2">Select Linode</Typography>
<BackupsWarning />
<LinodeSelectTable />
</Stack>
</Paper>
);
};
Loading

0 comments on commit 0f593e0

Please sign in to comment.