diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index c41022034..9a6380f5f 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -38,18 +38,20 @@ export function EditVpcSideModalForm() { query: { project }, }) - const onDismiss = () => navigate(pb.vpcs({ project })) - const editVpc = useApiMutation('vpcUpdate', { - onSuccess(vpc) { + onSuccess(updatedVpc) { queryClient.invalidateQueries('vpcList') - queryClient.setQueryData( - 'vpcView', - { path: { vpc: vpc.name }, query: { project } }, - vpc - ) - addToast({ content: 'Your VPC has been created' }) - onDismiss() + navigate(pb.vpc({ project, vpc: updatedVpc.name })) + addToast({ content: 'Your VPC has been updated' }) + + // Only invalidate if we're staying on the same page. If the name + // _has_ changed, invalidating vpcView causes an error page to flash + // while the loader for the target page is running because the current + // page's VPC gets cleared out while we're still on the page. If we're + // navigating to a different page, its query will fetch anew regardless. + if (vpc.name === updatedVpc.name) { + queryClient.invalidateQueries('vpcView') + } }, }) @@ -60,7 +62,7 @@ export function EditVpcSideModalForm() { form={form} formType="edit" resourceName="VPC" - onDismiss={onDismiss} + onDismiss={() => navigate(pb.vpc({ project, vpc: vpcName }))} onSubmit={({ name, description, dnsName }) => { editVpc.mutate({ path: { vpc: vpcName }, diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index ff7c1717a..5c5c5d912 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -5,13 +5,22 @@ * * Copyright Oxide Computer Company */ -import type { LoaderFunctionArgs } from 'react-router-dom' +import { useMemo } from 'react' +import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, +} from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -27,17 +36,51 @@ VpcPage.loader = async ({ params }: LoaderFunctionArgs) => { } export function VpcPage() { + const queryClient = useApiQueryClient() + const navigate = useNavigate() const vpcSelector = useVpcSelector() + const { project, vpc: vpcName } = vpcSelector const { data: vpc } = usePrefetchedApiQuery('vpcView', { - path: { vpc: vpcSelector.vpc }, - query: { project: vpcSelector.project }, + path: { vpc: vpcName }, + query: { project }, }) + const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { + onSuccess() { + queryClient.invalidateQueries('vpcList') + navigate(pb.vpcs({ project })) + addToast({ content: 'Your VPC has been deleted' }) + }, + }) + + const actions = useMemo( + () => [ + { + label: 'Edit', + onActivate() { + navigate(pb.vpcEdit(vpcSelector)) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deleteVpc({ path: { vpc: vpcName }, query: { project } }), + label: vpcName, + }), + className: 'destructive', + }, + ], + [deleteVpc, navigate, project, vpcName, vpcSelector] + ) + return ( <> }>{vpc.name} - +
+ + +
diff --git a/app/routes.tsx b/app/routes.tsx index f6f21f385..609a39f96 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -348,12 +348,6 @@ export const routes = createRoutesFromElements( element={} handle={{ crumb: 'New VPC' }} /> - } - loader={EditVpcSideModalForm.loader} - handle={{ crumb: 'Edit VPC' }} - /> @@ -365,6 +359,12 @@ export const routes = createRoutesFromElements( loader={VpcFirewallRulesTab.loader} /> } loader={VpcFirewallRulesTab.loader}> + } + loader={EditVpcSideModalForm.loader} + handle={{ crumb: 'Edit VPC' }} + /> { await expect(page.getByRole('cell', { name: 'allow-icmp' })).toBeVisible() }) +test('can edit VPC', async ({ page }) => { + // update the VPC name, starting from the VPCs list page + await page.goto('/projects/mock-project/vpcs') + await expectRowVisible(page.getByRole('table'), { name: 'mock-vpc' }) + await clickRowAction(page, 'mock-vpc', 'Edit') + await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc/edit') + await page.getByRole('textbox', { name: 'Name' }).first().fill('mock-vpc-2') + await page.getByRole('button', { name: 'Update VPC' }).click() + await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc-2/firewall-rules') + await expect(page.getByRole('heading', { name: 'mock-vpc-2' })).toBeVisible() + + // now update the VPC description, starting from the VPC view page + await page.getByRole('button', { name: 'VPC actions' }).click() + await page.getByRole('menuitem', { name: 'Edit' }).click() + await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc-2/edit') + await page.getByRole('textbox', { name: 'Description' }).fill('updated description') + await page.getByRole('button', { name: 'Update VPC' }).click() + await expect(page).toHaveURL('/projects/mock-project/vpcs/mock-vpc-2/firewall-rules') + await expect(page.getByText('descriptionupdated description')).toBeVisible() + + // go to the VPCs list page and verify the name and description change + await page.getByRole('link', { name: 'VPCs' }).click() + await expect(page.getByRole('table').locator('tbody >> tr')).toHaveCount(1) + await expectRowVisible(page.getByRole('table'), { + name: 'mock-vpc-2', + 'DNS name': 'mock-vpc', + description: 'updated description', + }) +}) + test('can create and delete subnet', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc') await page.getByRole('tab', { name: 'Subnets' }).click()