diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx new file mode 100644 index 000000000..7b74063fb --- /dev/null +++ b/app/components/IpPoolDetailSideModal.tsx @@ -0,0 +1,51 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { type IpPool } from '@oxide/api' +import { IpGlobal16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { IpVersionBadge } from '~/components/IpVersionBadge' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +type IpPoolDetailSideModalProps = { + pool: IpPool + onDismiss: () => void +} + +export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModalProps) { + return ( + + {pool.name} + + } + > + + + + + + + + {pool.poolType} + + + + + + + ) +} diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index d67476c1d..8339f6777 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -34,7 +34,7 @@ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { InstanceLink } from '~/table/cells/InstanceLinkCell' -import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -78,10 +78,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { .fetchQuery(q(api.ipPoolList, { query: { limit: ALL_ISH } })) .then((pools) => { for (const pool of pools.items) { - const { queryKey } = q(api.ipPoolView, { - path: { pool: pool.id }, - }) - queryClient.setQueryData(queryKey, pool) + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) } }), ]) diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx index 7ae397c11..bbb095afa 100644 --- a/app/pages/project/vpcs/VpcGatewaysTab.tsx +++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx @@ -11,10 +11,10 @@ import { createColumnHelper } from '@tanstack/react-table' import { useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, q, queryClient, type InternetGateway } from '~/api' +import { api, getListQFn, queryClient, type InternetGateway } from '~/api' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' -import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -83,10 +83,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { ), queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => { for (const pool of pools.items) { - const { queryKey } = q(api.ipPoolView, { - path: { pool: pool.id }, - }) - queryClient.setQueryData(queryKey, pool) + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) } }), ] satisfies Promise[]) diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index fc234191b..65b62fe38 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -6,33 +6,52 @@ * Copyright Oxide Computer Company */ import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' import { api, qErrorsAllowed } from '~/api' +import { IpPoolDetailSideModal } from '~/components/IpPoolDetailSideModal' +import { useIsInSideModal } from '~/ui/lib/modal-context' import { Tooltip } from '~/ui/lib/Tooltip' import { EmptyCell, SkeletonCell } from './EmptyCell' +import { ButtonCell } from './LinkCell' -export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { - const { data: result } = useQuery( - qErrorsAllowed( - api.ipPoolView, - { path: { pool: ipPoolId } }, - { - errorsExpected: { - explanation: 'the referenced IP pool may have been deleted.', - statusCode: 404, - }, - } - ) +export const ipPoolErrorsAllowedQuery = (ipPoolId: string) => + qErrorsAllowed( + api.ipPoolView, + { path: { pool: ipPoolId } }, + { + errorsExpected: { + explanation: 'the referenced IP pool may have been deleted.', + statusCode: 404, + }, + } ) + +/** + * Renders an IP pool name. In a table cell, clicking opens a side modal with + * pool details. Inside a side modal (detected via context) it shows the + * description in a tooltip. + */ +export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { + const inSideModal = useIsInSideModal() + const [showDetail, setShowDetail] = useState(false) + const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId)) if (!result) return // Defensive: the error case should never happen in practice. It should not be // possible for a resource to reference a pool without that pool existing. if (result.type === 'error') return const pool = result.data - return ( + return inSideModal ? ( {pool.name} + ) : ( + <> + setShowDetail(true)}>{pool.name} + {showDetail && ( + setShowDetail(false)} /> + )} + ) } diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index c33bfeef4..fee8a7df6 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -50,6 +50,23 @@ test('can create a floating IP', async ({ page }) => { }) }) +test('can view IP pool details from floating IP table', async ({ page }) => { + await page.goto(floatingIpsPage) + + // cola-float is in ip-pool-1; click the pool cell to open the detail modal + const row = page.getByRole('row', { name: /cola-float/ }) + await row.getByRole('button', { name: 'ip-pool-1' }).click() + + const dialog = page.getByRole('dialog', { name: 'IP pool details' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByText('public IPs')).toBeVisible() + await expect(dialog.getByText('v4')).toBeVisible() + await expect(dialog.getByText('unicast')).toBeVisible() + + await dialog.locator('footer').getByRole('button', { name: 'Close' }).click() + await expect(dialog).toBeHidden() +}) + test('can detach and attach a floating IP', async ({ page }) => { // check floating IP is visible on instance detail await page.goto('/projects/mock-project/instances/db1') diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 065358f48..7aeb8498d 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -34,6 +34,8 @@ test('can update a floating IP', async ({ page }) => { // Properties table should show resolved instance and pool names const dialog = page.getByRole('dialog') await expect(dialog.getByText('ip-pool-1')).toBeVisible() + // IP pool cells inside side modals should not open nested side modals + await expect(dialog.getByRole('button', { name: 'ip-pool-1' })).toBeHidden() // cola-float is attached to db1 await expect(dialog.getByRole('link', { name: 'db1' })).toBeVisible()