Skip to content

Commit 93ae439

Browse files
authored
Loading spinner in attach ephemeral IP modal (#3200)
Closes #3197. Turns out we were using a bespoke modal + form thing instead of our dedicated `ModalForm` component that handles the loading state in the button. Turns out there are a bunch of modal forms that aren't using `ModalForm` like they probably should. I'll fix the other ones in another PR.
1 parent d169f5b commit 93ae439

9 files changed

Lines changed: 83 additions & 47 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
- Store API response objects in the mock tables when possible so state persists across calls.
6868
- Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`).
6969
- All UUIDs in `mock-api/` must be valid RFC 4122 (a safety test enforces this). Use `uuidgen` to generate them—do not hand-write UUIDs.
70+
- To test error paths in e2e tests, do **not** use `page.route` to intercept API calls. Instead, add a sentinel (a well-known name, id, or fixture value) to the mock handler that makes it return the desired error, and drive the test through the real UI. Branch on an input the user controls (e.g., `if (body.name === '<sentinel>') throw 500`) or, if no such input is available, add a sentinel fixture that is otherwise inert. Keeping the mock backend authoritative means the failure path is reproducible in the dev server too, not only from tests.
7071
- MSW starts fresh with a new db on every page load, so in E2E tests, use client-side navigation (click links/breadcrumbs) after mutations instead of `page.goto` to preserve db state within a test.
7172

7273
# Routing

app/components/AttachEphemeralIpModal.tsx

Lines changed: 30 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import {
2121
type IpVersion,
2222
} from '~/api'
2323
import { ListboxField } from '~/components/form/fields/ListboxField'
24+
import { ModalForm } from '~/components/form/ModalForm'
2425
import { HL } from '~/components/HL'
2526
import { toPoolItem } from '~/components/PoolListboxItem'
2627
import { useInstanceSelector } from '~/hooks/use-params'
2728
import { addToast } from '~/stores/toast'
2829
import { Message } from '~/ui/lib/Message'
29-
import { Modal } from '~/ui/lib/Modal'
3030
import { ALL_ISH } from '~/util/consts'
3131

3232
type AttachEphemeralIpModalProps = {
@@ -70,49 +70,40 @@ export const AttachEphemeralIpModal = ({
7070
const form = useForm({ defaultValues: { pool: defaultPool } })
7171
const pool = form.watch('pool')
7272

73-
const disabledReason =
73+
const submitDisabled =
7474
compatibleUnicastPools.length === 0
7575
? 'No compatible unicast pools available for this instance'
7676
: !pool
77-
? 'Select a pool to continue'
77+
? 'Select a pool'
7878
: undefined
7979

8080
return (
81-
<Modal isOpen title="Attach ephemeral IP" onDismiss={onDismiss}>
82-
<Modal.Body>
83-
<Modal.Section>
84-
{infoMessage && <Message variant="info" content={infoMessage} />}
85-
<form>
86-
<ListboxField
87-
name="pool"
88-
label="Pool"
89-
control={form.control}
90-
items={sortPools(compatibleUnicastPools).map(toPoolItem)}
91-
disabled={compatibleUnicastPools.length === 0}
92-
placeholder="Select a pool"
93-
noItemsPlaceholder="No pools available"
94-
/>
95-
</form>
96-
{instanceEphemeralIpAttach.error && (
97-
<p className="text-error mt-4">{instanceEphemeralIpAttach.error.message}</p>
98-
)}
99-
</Modal.Section>
100-
</Modal.Body>
101-
<Modal.Footer
102-
actionText="Attach"
103-
disabled={!!disabledReason}
104-
disabledReason={disabledReason}
105-
onAction={() => {
106-
instanceEphemeralIpAttach.mutate({
107-
path: { instance },
108-
query: { project },
109-
body: {
110-
poolSelector: { type: 'explicit', pool },
111-
},
112-
})
113-
}}
114-
onDismiss={onDismiss}
115-
></Modal.Footer>
116-
</Modal>
81+
<ModalForm
82+
form={form}
83+
title="Attach ephemeral IP"
84+
onDismiss={onDismiss}
85+
submitLabel="Attach"
86+
submitDisabled={submitDisabled}
87+
submitError={instanceEphemeralIpAttach.error}
88+
loading={instanceEphemeralIpAttach.isPending}
89+
onSubmit={({ pool }) => {
90+
instanceEphemeralIpAttach.mutate({
91+
path: { instance },
92+
query: { project },
93+
body: { poolSelector: { type: 'explicit', pool } },
94+
})
95+
}}
96+
>
97+
{infoMessage && <Message variant="info" content={infoMessage} />}
98+
<ListboxField
99+
name="pool"
100+
label="Pool"
101+
control={form.control}
102+
items={sortPools(compatibleUnicastPools).map(toPoolItem)}
103+
disabled={compatibleUnicastPools.length === 0}
104+
placeholder="Select a pool"
105+
noItemsPlaceholder="No pools available"
106+
/>
107+
</ModalForm>
117108
)
118109
}

app/components/AttachFloatingIpModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export const AttachFloatingIpModal = ({
103103
body: { kind: 'instance', parent: instance.id },
104104
})
105105
}
106-
submitDisabled={!floatingIp}
106+
submitDisabled={!floatingIp ? 'Select a floating IP' : undefined}
107107
>
108108
<Message
109109
variant="info"

app/components/form/ModalForm.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ type ModalFormProps<TFieldValues extends FieldValues> = {
1818
form: UseFormReturn<TFieldValues>
1919
children: ReactNode
2020

21-
/** Must be provided with a reason describing why it's disabled */
22-
submitDisabled?: boolean
21+
/** Reason the submit button is disabled (shown as a tooltip). Undefined means enabled. */
22+
submitDisabled?: string
2323
onSubmit: (values: TFieldValues) => void
2424
submitLabel: string
2525

@@ -35,7 +35,7 @@ export function ModalForm<TFieldValues extends FieldValues>({
3535
form,
3636
children,
3737
onDismiss,
38-
submitDisabled = false,
38+
submitDisabled,
3939
submitError,
4040
title,
4141
onSubmit,
@@ -77,7 +77,8 @@ export function ModalForm<TFieldValues extends FieldValues>({
7777
onDismiss={onDismiss}
7878
formId={id}
7979
actionText={submitLabel}
80-
disabled={submitDisabled}
80+
disabled={!!submitDisabled}
81+
disabledReason={submitDisabled}
8182
actionLoading={loading || isSubmitting}
8283
/>
8384
</Modal>

mock-api/ip-pool.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,26 @@ export const ipPool6Multicast: Json<IpPool> = {
7272
pool_type: 'multicast',
7373
}
7474

75+
// Sentinel pool: selecting this pool in an ephemeral-IP attach request causes
76+
// the mock handler to return a 500 so tests can exercise the failure path.
77+
export const ipPoolEphemeralAttachFail: Json<IpPool> = {
78+
id: 'a82e20a3-1fb3-4d72-910a-2883298304a2',
79+
name: 'attach-fail',
80+
description: 'Sentinel: ephemeral IP attach returns 500',
81+
time_created: new Date().toISOString(),
82+
time_modified: new Date().toISOString(),
83+
ip_version: 'v6',
84+
pool_type: 'unicast',
85+
}
86+
7587
export const ipPools: Json<IpPool>[] = [
7688
ipPool1,
7789
ipPool2,
7890
ipPool3,
7991
ipPool4,
8092
ipPool5Multicast,
8193
ipPool6Multicast,
94+
ipPoolEphemeralAttachFail,
8295
]
8396

8497
export const ipPoolSilos: Json<IpPoolSiloLink>[] = [
@@ -105,6 +118,12 @@ export const ipPoolSilos: Json<IpPoolSiloLink>[] = [
105118
is_default: true,
106119
},
107120

121+
{
122+
ip_pool_id: ipPoolEphemeralAttachFail.id,
123+
silo_id: defaultSilo.id,
124+
is_default: false,
125+
},
126+
108127
// myriad silo: v4-only default
109128
{
110129
ip_pool_id: ipPool1.id,

mock-api/msw/handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,10 @@ export const handlers = makeHandlers({
962962
const instanceProject = lookup.project(projectParams)
963963
// Ephemeral IPs must use unicast pools
964964
const pool = resolvePoolSelector(body.pool_selector, 'unicast', instanceProject.silo_id)
965+
966+
// Sentinel: see ipPoolEphemeralAttachFail in ../ip-pool.ts
967+
if (pool.name === 'attach-fail') throw 'Cannot attach ephemeral IP'
968+
965969
const ip = getIpFromPool(pool)
966970

967971
// Validate that external IP version matches primary NIC's IP stack

test/e2e/instance-networking.e2e.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,26 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page
212212
})
213213
})
214214

215+
test('Attach ephemeral IP — error renders in modal, not toast', async ({ page }) => {
216+
// Selecting the sentinel `attach-fail` pool causes the mock handler to 500.
217+
// See ipPoolEphemeralAttachFail.
218+
await page.goto('/projects/mock-project/instances/db1/networking')
219+
await page.getByRole('button', { name: 'Attach ephemeral IP' }).click()
220+
221+
const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' })
222+
await expect(modal).toBeVisible()
223+
224+
await page.getByLabel('Pool').click()
225+
await page.getByRole('option', { name: 'attach-fail' }).click()
226+
await page.getByRole('button', { name: 'Attach', exact: true }).click()
227+
228+
const errorText = 'Cannot attach ephemeral IP'
229+
await expect(modal.getByText(errorText)).toBeVisible()
230+
await expect(page.getByTestId('Toasts')).not.toContainText(errorText)
231+
// Modal stays open so the user can retry or dismiss
232+
await expect(modal).toBeVisible()
233+
})
234+
215235
test('Instance networking tab — floating IPs', async ({ page }) => {
216236
await page.goto('/projects/mock-project/instances/db1/networking')
217237
const externalIpTable = page.getByRole('table', { name: 'External IPs' })

test/e2e/ip-pools.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ test('IP pool list', async ({ page }) => {
1919

2020
const table = page.getByRole('table')
2121

22-
await expect(table.getByRole('row')).toHaveCount(7) // header + 6 rows (includes multicast pools)
22+
await expect(table.getByRole('row')).toHaveCount(8) // header + 7 rows (includes multicast pools and `attach-fail` sentinel)
2323

2424
await expectRowVisible(table, {
2525
name: 'ip-pool-1',

test/e2e/silos.e2e.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ test('Silo IP pools', async ({ page }) => {
269269
name: 'ip-pool-6-multicast-v6default',
270270
Version: 'v6',
271271
})
272-
await expect(table.getByRole('row')).toHaveCount(5) // header + 4
272+
await expect(table.getByRole('row')).toHaveCount(6) // header + 4 + sentinel `attach-fail`
273273

274274
// clicking on pool goes to pool detail
275275
await page.getByRole('link', { name: 'ip-pool-1' }).click()
@@ -315,7 +315,7 @@ test('Silo IP pools link pool', async ({ page }) => {
315315
name: 'ip-pool-6-multicast-v6default',
316316
Version: 'v6',
317317
})
318-
await expect(table.getByRole('row')).toHaveCount(5) // header + 4
318+
await expect(table.getByRole('row')).toHaveCount(6) // header + 4 + sentinel `attach-fail`
319319

320320
const modal = page.getByRole('dialog', { name: 'Link pool' })
321321
await expect(modal).toBeHidden()

0 commit comments

Comments
 (0)