diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx
index b50a73ab4..cee9a721b 100644
--- a/app/forms/vpc-edit.tsx
+++ b/app/forms/vpc-edit.tsx
@@ -37,18 +37,22 @@ export function EditVpcSideModalForm() {
query: { project },
})
- const onDismiss = () => navigate(pb.vpcs({ project }))
+ const onDismiss = () => navigate(pb.vpc({ project, vpc: vpcName }))
const editVpc = useApiMutation('vpcUpdate', {
- onSuccess(vpc) {
+ onSuccess(updatedVpc) {
+ navigate(pb.vpc({ project, vpc: updatedVpc.name }))
queryClient.invalidateQueries('vpcList')
- queryClient.setQueryData(
- 'vpcView',
- { path: { vpc: vpc.name }, query: { project } },
- vpc
- )
- addToast({ content: 'Your VPC has been created' })
- onDismiss()
+
+ // Only invalidate if we're staying on the same page. If the name
+ // _has_ changed, invalidating ipPoolView causes an error page to flash
+ // while the loader for the target page is running because the current
+ // page's pool 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')
+ }
+ addToast({ content: 'Your VPC has been updated' })
},
})
diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx
index b1bf4073e..0cc924c0e 100644
--- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx
+++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx
@@ -5,13 +5,17 @@
*
* 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, 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'
+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 +31,50 @@ VpcPage.loader = async ({ params }: LoaderFunctionArgs) => {
}
export function VpcPage() {
+ 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() {
+ navigate(pb.vpcs({ project }))
+ apiQueryClient.invalidateQueries('vpcList')
+ addToast({ content: 'VPC deleted' })
+ },
+ })
+
+ const actions = useMemo(
+ () => [
+ {
+ label: 'Edit',
+ onActivate() {
+ navigate(pb.vpcEdit(vpcSelector))
+ },
+ },
+ {
+ label: 'Delete',
+ onActivate: confirmDelete({
+ doDelete: () => deleteVpc({ path: { vpc: vpc.name }, query: { project } }),
+ label: vpc.name,
+ }),
+ className: 'destructive',
+ },
+ ],
+ [deleteVpc, navigate, vpcSelector, project, vpc.name]
+ )
+
return (
<>
}>{vpc.name}
-
+
+
+
+
diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx
index 87f01df47..8b4d8a931 100644
--- a/app/pages/project/vpcs/VpcsPage.tsx
+++ b/app/pages/project/vpcs/VpcsPage.tsx
@@ -22,6 +22,7 @@ import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/r
import { DocsPopover } from '~/components/DocsPopover'
import { getProjectSelector, useProjectSelector, useQuickActions } from '~/hooks'
import { confirmDelete } from '~/stores/confirm-delete'
+import { addToast } from '~/stores/toast'
import { SkeletonCell } from '~/table/cells/EmptyCell'
import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell'
import { getActionsCol, type MenuAction } from '~/table/columns/action-col'
@@ -83,6 +84,7 @@ export function VpcsPage() {
const deleteVpc = useApiMutation('vpcDelete', {
onSuccess() {
queryClient.invalidateQueries('vpcList')
+ addToast({ content: 'VPC deleted' })
},
})
@@ -147,7 +149,7 @@ export function VpcsPage() {
}>VPCs
-
+
New Vpc
} />
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')
+
+ // 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()