Skip to content

Commit e8175d3

Browse files
authored
Move IP Pools edit form to view page (#2405)
1 parent bdb55b8 commit e8175d3

File tree

5 files changed

+103
-21
lines changed

5 files changed

+103
-21
lines changed

app/forms/ip-pool-edit.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,27 @@ EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
3030
export function EditIpPoolSideModalForm() {
3131
const queryClient = useApiQueryClient()
3232
const navigate = useNavigate()
33-
3433
const poolSelector = useIpPoolSelector()
3534

36-
const onDismiss = () => navigate(pb.ipPools())
37-
3835
const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector })
3936

37+
const form = useForm({ defaultValues: pool })
38+
const onDismiss = () => navigate(pb.ipPool({ pool: poolSelector.pool }))
39+
4040
const editPool = useApiMutation('ipPoolUpdate', {
4141
onSuccess(_pool) {
4242
queryClient.invalidateQueries('ipPoolList')
43+
if (pool.name !== _pool.name) {
44+
// as the pool's name has changed, we need to navigate to an updated URL
45+
navigate(pb.ipPool({ pool: _pool.name }))
46+
} else {
47+
queryClient.invalidateQueries('ipPoolView')
48+
onDismiss()
49+
}
4350
addToast({ content: 'Your IP pool has been updated' })
44-
onDismiss()
4551
},
4652
})
4753

48-
const form = useForm({ defaultValues: pool })
49-
5054
return (
5155
<SideModalForm
5256
form={form}

app/pages/system/networking/IpPoolPage.tsx

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { createColumnHelper } from '@tanstack/react-table'
1010
import { useCallback, useMemo, useState } from 'react'
11-
import { Outlet, type LoaderFunctionArgs } from 'react-router-dom'
11+
import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1212

1313
import {
1414
apiQueryClient,
@@ -26,9 +26,11 @@ import { CapacityBar } from '~/components/CapacityBar'
2626
import { DocsPopover } from '~/components/DocsPopover'
2727
import { ComboboxField } from '~/components/form/fields/ComboboxField'
2828
import { HL } from '~/components/HL'
29+
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
2930
import { QueryParamTabs } from '~/components/QueryParamTabs'
3031
import { getIpPoolSelector, useForm, useIpPoolSelector } from '~/hooks'
3132
import { confirmAction } from '~/stores/confirm-action'
33+
import { confirmDelete } from '~/stores/confirm-delete'
3234
import { addToast } from '~/stores/toast'
3335
import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell'
3436
import { SkeletonCell } from '~/table/cells/EmptyCell'
@@ -46,9 +48,10 @@ import { TipIcon } from '~/ui/lib/TipIcon'
4648
import { docLinks } from '~/util/links'
4749
import { pb } from '~/util/path-builder'
4850

51+
const query = { limit: PAGE_SIZE }
52+
4953
IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) {
5054
const { pool } = getIpPoolSelector(params)
51-
const query = { limit: PAGE_SIZE }
5255
await Promise.all([
5356
apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }),
5457
apiQueryClient.prefetchQuery('ipPoolSiloList', { path: { pool }, query }),
@@ -70,16 +73,54 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) {
7073
export function IpPoolPage() {
7174
const poolSelector = useIpPoolSelector()
7275
const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector })
76+
const { data: ranges } = usePrefetchedApiQuery('ipPoolRangeList', {
77+
path: poolSelector,
78+
query,
79+
})
80+
const navigate = useNavigate()
81+
const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', {
82+
onSuccess() {
83+
apiQueryClient.invalidateQueries('ipPoolList')
84+
navigate(pb.ipPools())
85+
addToast({ content: 'IP pool deleted' })
86+
},
87+
})
88+
89+
const actions = useMemo(
90+
() => [
91+
{
92+
label: 'Edit',
93+
onActivate() {
94+
navigate(pb.ipPoolEdit(poolSelector))
95+
},
96+
},
97+
{
98+
label: 'Delete',
99+
onActivate: confirmDelete({
100+
doDelete: () => deletePool({ path: { pool: pool.name } }),
101+
label: pool.name,
102+
}),
103+
disabled:
104+
!!ranges.items.length && 'IP pool cannot be deleted while it contains IP ranges',
105+
className: ranges.items.length ? '' : 'destructive',
106+
},
107+
],
108+
[deletePool, navigate, poolSelector, pool.name, ranges.items]
109+
)
110+
73111
return (
74112
<>
75113
<PageHeader>
76114
<PageTitle icon={<IpGlobal24Icon />}>{pool.name}</PageTitle>
77-
<DocsPopover
78-
heading="IP pools"
79-
icon={<IpGlobal16Icon />}
80-
summary="IP pools are collections of external IPs you can assign to silos. When a pool is linked to a silo, users in that silo can allocate IPs from the pool for their instances."
81-
links={[docLinks.systemIpPools]}
82-
/>
115+
<div className="inline-flex gap-2">
116+
<DocsPopover
117+
heading="IP pools"
118+
icon={<IpGlobal16Icon />}
119+
summary="IP pools are collections of external IPs you can assign to silos. When a pool is linked to a silo, users in that silo can allocate IPs from the pool for their instances."
120+
links={[docLinks.systemIpPools]}
121+
/>
122+
<MoreActionsMenu label="IP pool actions" actions={actions} />
123+
</div>
83124
</PageHeader>
84125
<UtilizationBars />
85126
<QueryParamTabs className="full-width" defaultValue="ranges">

app/pages/system/networking/IpPoolsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DocsPopover } from '~/components/DocsPopover'
2323
import { IpUtilCell } from '~/components/IpPoolUtilization'
2424
import { useQuickActions } from '~/hooks'
2525
import { confirmDelete } from '~/stores/confirm-delete'
26+
import { addToast } from '~/stores/toast'
2627
import { SkeletonCell } from '~/table/cells/EmptyCell'
2728
import { makeLinkCell } from '~/table/cells/LinkCell'
2829
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
@@ -79,6 +80,7 @@ export function IpPoolsPage() {
7980
const deletePool = useApiMutation('ipPoolDelete', {
8081
onSuccess() {
8182
apiQueryClient.invalidateQueries('ipPoolList')
83+
addToast({ content: 'IP pool deleted' })
8284
},
8385
})
8486

app/routes.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,6 @@ export const routes = createRoutesFromElements(
198198
>
199199
<Route path="ip-pools" element={null} />
200200
<Route path="ip-pools-new" element={<CreateIpPoolSideModalForm />} />
201-
<Route
202-
path="ip-pools/:pool/edit"
203-
element={<EditIpPoolSideModalForm />}
204-
loader={EditIpPoolSideModalForm.loader}
205-
handle={{ crumb: 'Edit IP pool' }}
206-
/>
207201
</Route>
208202
</Route>
209203
<Route path="networking/ip-pools" handle={{ crumb: 'IP pools' }}>
@@ -213,6 +207,12 @@ export const routes = createRoutesFromElements(
213207
loader={IpPoolPage.loader}
214208
handle={{ crumb: poolCrumb }}
215209
>
210+
<Route
211+
path="edit"
212+
element={<EditIpPoolSideModalForm />}
213+
loader={EditIpPoolSideModalForm.loader}
214+
handle={{ crumb: 'Edit IP pool' }}
215+
/>
216216
<Route path="ranges-add" element={<IpPoolAddRangeSideModalForm />} />
217217
</Route>
218218
</Route>

test/e2e/ip-pools.e2e.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ test('IP pool link silo', async ({ page }) => {
110110
await expectRowVisible(table, { Silo: 'myriad', 'Pool is silo default': '' })
111111
})
112112

113-
test('IP pool delete', async ({ page }) => {
113+
test('IP pool delete from IP Pools list page', async ({ page }) => {
114114
await page.goto('/system/networking/ip-pools')
115115

116116
// can't delete a pool containing ranges
@@ -133,6 +133,24 @@ test('IP pool delete', async ({ page }) => {
133133
await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeHidden()
134134
})
135135

136+
test('IP pool delete from IP Pool view page', async ({ page }) => {
137+
// can't delete a pool containing ranges
138+
await page.goto('/system/networking/ip-pools/ip-pool-1')
139+
await page.getByRole('button', { name: 'IP pool actions' }).click()
140+
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled()
141+
142+
// can delete a pool with no ranges
143+
await page.goto('/system/networking/ip-pools/ip-pool-3')
144+
await page.getByRole('button', { name: 'IP pool actions' }).click()
145+
await page.getByRole('menuitem', { name: 'Delete' }).click()
146+
await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible()
147+
await page.getByRole('button', { name: 'Confirm' }).click()
148+
149+
// get redirected back to the list after successful delete
150+
await expect(page).toHaveURL('/system/networking/ip-pools')
151+
await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeHidden()
152+
})
153+
136154
test('IP pool create', async ({ page }) => {
137155
await page.goto('/system/networking/ip-pools')
138156
await expect(page.getByRole('cell', { name: 'another-pool' })).toBeHidden()
@@ -155,6 +173,23 @@ test('IP pool create', async ({ page }) => {
155173
})
156174
})
157175

176+
test('IP pool edit', async ({ page }) => {
177+
await page.goto('/system/networking/ip-pools/ip-pool-3')
178+
await page.getByRole('button', { name: 'IP pool actions' }).click()
179+
await page.getByRole('menuitem', { name: 'Edit' }).click()
180+
181+
const modal = page.getByRole('dialog', { name: 'Edit IP pool' })
182+
await expect(modal).toBeVisible()
183+
184+
await page.getByRole('textbox', { name: 'Name' }).fill('updated-pool')
185+
await page.getByRole('textbox', { name: 'Description' }).fill('an updated description')
186+
await page.getByRole('button', { name: 'Update IP pool' }).click()
187+
188+
await expect(modal).toBeHidden()
189+
await expect(page).toHaveURL('/system/networking/ip-pools/updated-pool')
190+
await expect(page.getByRole('heading', { name: 'updated-pool' })).toBeVisible()
191+
})
192+
158193
test('IP range validation and add', async ({ page }) => {
159194
await page.goto('/system/networking/ip-pools/ip-pool-2')
160195

0 commit comments

Comments
 (0)