From 28ef14a4df54efdb6016a1dffbb9fe9afe8fe85d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 12:24:35 -0500 Subject: [PATCH 01/10] quick actions: "go up" actions powered by breadcrumbs --- app/hooks/use-quick-actions.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index eaf16463b7..5ee1e084fd 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -9,6 +9,7 @@ import { useEffect, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router' import { create } from 'zustand' +import { useCrumbs } from '~/hooks/use-crumbs' import { useCurrentUser } from '~/hooks/use-current-user' import { ActionMenu, type QuickActionItem } from '~/ui/lib/ActionMenu' import { invariant } from '~/util/invariant' @@ -80,6 +81,23 @@ function useGlobalActions() { }, [location.pathname, navigate, me.fleetViewer]) } +/** Derive "Go up" actions from breadcrumb ancestors of the current page */ +function useParentActions() { + const crumbs = useCrumbs() + const navigate = useNavigate() + + return useMemo(() => { + const navCrumbs = crumbs.filter((c) => !c.titleOnly) + // Everything except the last crumb (the current page) + const parentCrumbs = navCrumbs.slice(0, -1) + return parentCrumbs.map((c) => ({ + value: c.label, + onSelect: () => navigate(c.path), + navGroup: 'Go up', + })) + }, [crumbs, navigate]) +} + /** * Register action items with the global quick actions menu. `itemsToAdd` must * be memoized by the caller, otherwise the effect will run too often. @@ -93,16 +111,17 @@ export function useQuickActions(itemsToAdd: QuickActionItem[]) { // Add routes without declaring them in every `useQuickActions` call const globalItems = useGlobalActions() + const parentItems = useParentActions() useEffect(() => { - const allItems = [...itemsToAdd, ...globalItems] + const allItems = [...itemsToAdd, ...globalItems, ...parentItems] invariant( allItems.length === new Set(allItems.map((i) => i.value)).size, 'Items being added to the list of quick actions must have unique `value` values.' ) addActions(allItems) return () => removeActions(allItems) - }, [itemsToAdd, globalItems, location.pathname]) + }, [itemsToAdd, globalItems, parentItems, location.pathname]) } function toggleDialog(e: Mousetrap.ExtendedKeyboardEvent) { From 1bd182f074cd0c1ed2857230aa97a6751295b470 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 12:26:55 -0500 Subject: [PATCH 02/10] quick actions: ctrl-n/p go up and down --- app/ui/lib/ActionMenu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index 29ac848281..a50e15ecc7 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -104,10 +104,12 @@ export function ActionMenu(props: ActionMenuProps) { e.preventDefault() onDismiss() } - } else if (e.key === KEYS.down) { + } else if (e.key === KEYS.down || (e.ctrlKey && e.key === 'n')) { + e.preventDefault() const newIdx = selectedIdx === lastIdx ? 0 : selectedIdx + 1 setSelectedIdx(newIdx) - } else if (e.key === KEYS.up) { + } else if (e.key === KEYS.up || (e.ctrlKey && e.key === 'p')) { + e.preventDefault() const newIdx = selectedIdx === 0 ? lastIdx : selectedIdx - 1 setSelectedIdx(newIdx) } From 26ae2fa8bb5e93747b50b682216919b21763c088 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 12:30:33 -0500 Subject: [PATCH 03/10] better e2e tests for quick actions --- test/e2e/action-menu.e2e.ts | 77 +++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/test/e2e/action-menu.e2e.ts b/test/e2e/action-menu.e2e.ts index 6aadc1760d..9bf6f60739 100644 --- a/test/e2e/action-menu.e2e.ts +++ b/test/e2e/action-menu.e2e.ts @@ -16,9 +16,9 @@ const openActionMenu = async (page: Page) => { await expect(page.getByText('Enterto submit')).toBeVisible() } -test('Ensure that the Enter key in the ActionMenu doesn’t prematurely submit a linked form', async ({ - page, -}) => { +const getSelectedItem = (page: Page) => page.getByRole('option', { selected: true }) + +test('Enter key does not prematurely submit a linked form', async ({ page }) => { await page.goto('/system/silos') await openActionMenu(page) // "New silo" is the first item in the list, so we can just hit enter to open the modal @@ -27,3 +27,74 @@ test('Ensure that the Enter key in the ActionMenu doesn’t prematurely submit a // make sure error text is not visible await expectNotVisible(page, [page.getByText('Name is required')]) }) + +test('Ctrl+N/P navigate up and down', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + const selected = getSelectedItem(page) + // first item is selected by default + await expect(selected).toHaveText('New instance') + + // Ctrl+N moves down + await page.keyboard.press('Control+n') + await expect(selected).not.toHaveText('New instance') + const secondItem = await selected.textContent() + + // Ctrl+P moves back up + await page.keyboard.press('Control+p') + await expect(selected).toHaveText('New instance') + + // Ctrl+N again gets the same second item + await page.keyboard.press('Control+n') + await expect(selected).toHaveText(secondItem!) +}) + +test('Arrow keys navigate up and down', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + const selected = getSelectedItem(page) + await expect(selected).toHaveText('New instance') + + await page.keyboard.press('ArrowDown') + await expect(selected).not.toHaveText('New instance') + + await page.keyboard.press('ArrowUp') + await expect(selected).toHaveText('New instance') +}) + +test('search filters items', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + // type a search query that matches a specific instance + await page.keyboard.type('db1') + // the matching item should be visible + await expect(page.getByRole('option', { name: 'db1' })).toBeVisible() + // an unrelated item should be filtered out + await expect(page.getByRole('option', { name: 'New instance' })).toBeHidden() +}) + +test('"Go up" actions derived from breadcrumbs', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + await openActionMenu(page) + + // breadcrumb ancestors should appear in a "Go up" group + await expect(page.getByText('Go up')).toBeVisible() + await expect(page.getByRole('option', { name: 'Projects' })).toBeVisible() + await expect(page.getByRole('option', { name: 'mock-project' })).toBeVisible() + + // selecting "Projects" navigates to the projects page + await page.keyboard.type('Projects') + await page.keyboard.press('Enter') + await expect(page).toHaveURL('/projects') +}) + +test('dismiss with Escape', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + await page.keyboard.press('Escape') + await expect(page.getByText('Enterto submit')).toBeHidden() +}) From 25e9d748f44dc71471b8e1b2e59af45f3f1f14a7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 13:11:21 -0500 Subject: [PATCH 04/10] rework quick actions data model for better deduping, real links --- app/hooks/use-quick-actions.tsx | 127 ++++++++++-------- app/layouts/ProjectLayoutBase.tsx | 8 +- app/layouts/SettingsLayout.tsx | 8 +- app/layouts/SiloLayout.tsx | 8 +- app/layouts/SystemLayout.tsx | 15 ++- app/pages/ProjectsPage.tsx | 8 +- app/pages/SiloAccessPage.tsx | 1 + app/pages/SiloImagesPage.tsx | 9 +- .../project/access/ProjectAccessPage.tsx | 1 + app/pages/project/affinity/AffinityPage.tsx | 12 +- app/pages/project/disks/DisksPage.tsx | 11 +- .../project/floating-ips/FloatingIpsPage.tsx | 8 +- app/pages/project/images/ImagesPage.tsx | 11 +- app/pages/project/instances/InstancesPage.tsx | 11 +- app/pages/project/snapshots/SnapshotsPage.tsx | 5 +- app/pages/project/vpcs/VpcsPage.tsx | 8 +- app/pages/system/networking/IpPoolsPage.tsx | 8 +- app/pages/system/silos/SilosPage.tsx | 11 +- app/ui/lib/ActionMenu.tsx | 110 +++++++++------ 19 files changed, 217 insertions(+), 163 deletions(-) diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index 5ee1e084fd..2900083fc2 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -5,46 +5,51 @@ * * Copyright Oxide Computer Company */ -import { useEffect, useMemo } from 'react' -import { useLocation, useNavigate } from 'react-router' +import { useEffect, useId, useMemo } from 'react' +import { useLocation } from 'react-router' import { create } from 'zustand' import { useCrumbs } from '~/hooks/use-crumbs' import { useCurrentUser } from '~/hooks/use-current-user' import { ActionMenu, type QuickActionItem } from '~/ui/lib/ActionMenu' -import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' import { useKey } from './use-key' -type Items = QuickActionItem[] +export type { QuickActionItem } type StoreState = { - items: Items + // Multiple useQuickActions() hooks are active simultaneously — e.g., a + // layout registers "Create project" while a nested page registers "Create + // instance." Each caller is called a source and gets its own slot keyed by + // React useId(), so when a page unmounts, its cleanup removes only its items, + // leaving the layout's items intact. The map is flattened into a single list + // at render time. + itemsBySource: Map isOpen: boolean } -// TODO: dedupe by group and value together so we can have, e.g., both silo and -// system utilization at the same time - -// removeByValue dedupes items so they can be added as many times as we want -// without appearing in the menu multiple times -const removeByValue = (items: Items, toRemove: Items) => { - const valuesToRemove = new Set(toRemove.map((i) => i.value)) - return items.filter((i) => !valuesToRemove.has(i.value)) -} - -const useStore = create(() => ({ items: [], isOpen: false })) +const useStore = create(() => ({ itemsBySource: new Map(), isOpen: false })) // zustand docs say it's fine not to put your setters in the store // https://github.com/pmndrs/zustand/blob/0426978/docs/guides/practice-with-no-store-actions.md -function addActions(toAdd: Items) { - useStore.setState(({ items }) => ({ items: removeByValue(items, toAdd).concat(toAdd) })) +// These create a new Map each time because zustand uses reference equality to +// detect changes — mutating in place wouldn't trigger a re-render. +function setSourceItems(sourceId: string, items: QuickActionItem[]) { + useStore.setState(({ itemsBySource }) => { + const next = new Map(itemsBySource) + next.set(sourceId, items) + return { itemsBySource: next } + }) } -function removeActions(toRemove: Items) { - useStore.setState(({ items }) => ({ items: removeByValue(items, toRemove) })) +function clearSource(sourceId: string) { + useStore.setState(({ itemsBySource }) => { + const next = new Map(itemsBySource) + next.delete(sourceId) + return { itemsBySource: next } + }) } export function openQuickActions() { @@ -55,79 +60,69 @@ function closeQuickActions() { useStore.setState({ isOpen: false }) } -function useGlobalActions() { +function useGlobalActions(): QuickActionItem[] { const location = useLocation() - const navigate = useNavigate() const { me } = useCurrentUser() return useMemo(() => { - const actions = [] - // only add settings link if we're not on a settings page - if (!location.pathname.startsWith('/settings/')) { - actions.push({ - navGroup: 'User', - value: 'Settings', - onSelect: () => navigate(pb.profile()), - }) - } + const actions: QuickActionItem[] = [] if (me.fleetViewer && !location.pathname.startsWith('/system/')) { actions.push({ + kind: 'link', navGroup: 'System', value: 'Manage system', - onSelect: () => navigate(pb.silos()), + to: pb.silos(), + }) + } + if (!location.pathname.startsWith('/settings/')) { + actions.push({ + kind: 'link', + navGroup: 'User', + value: 'Settings', + to: pb.profile(), }) } return actions - }, [location.pathname, navigate, me.fleetViewer]) + }, [location.pathname, me.fleetViewer]) } /** Derive "Go up" actions from breadcrumb ancestors of the current page */ -function useParentActions() { +function useParentActions(): QuickActionItem[] { const crumbs = useCrumbs() - const navigate = useNavigate() return useMemo(() => { const navCrumbs = crumbs.filter((c) => !c.titleOnly) // Everything except the last crumb (the current page) const parentCrumbs = navCrumbs.slice(0, -1) return parentCrumbs.map((c) => ({ + kind: 'link', value: c.label, - onSelect: () => navigate(c.path), + to: c.path, navGroup: 'Go up', })) - }, [crumbs, navigate]) + }, [crumbs]) } /** - * Register action items with the global quick actions menu. `itemsToAdd` must + * Register action items with the global quick actions menu. `items` must * be memoized by the caller, otherwise the effect will run too often. * - * The idea here is that any component in the tree can register actions to go in - * the menu and having them appear when the component is mounted and not appear - * when the component is unmounted Just Works. + * Each component instance gets its own source slot (via useId), so items are + * cleaned up automatically when the component unmounts without affecting + * other sources' registrations. */ -export function useQuickActions(itemsToAdd: QuickActionItem[]) { - const location = useLocation() - - // Add routes without declaring them in every `useQuickActions` call - const globalItems = useGlobalActions() - const parentItems = useParentActions() +export function useQuickActions(items: QuickActionItem[]) { + const sourceId = useId() useEffect(() => { - const allItems = [...itemsToAdd, ...globalItems, ...parentItems] - invariant( - allItems.length === new Set(allItems.map((i) => i.value)).size, - 'Items being added to the list of quick actions must have unique `value` values.' - ) - addActions(allItems) - return () => removeActions(allItems) - }, [itemsToAdd, globalItems, parentItems, location.pathname]) + setSourceItems(sourceId, items) + return () => clearSource(sourceId) + }, [sourceId, items]) } function toggleDialog(e: Mousetrap.ExtendedKeyboardEvent) { - const { items, isOpen } = useStore.getState() - - if (items.length > 0 && !isOpen) { + const { itemsBySource, isOpen } = useStore.getState() + if (itemsBySource.size > 0 && !isOpen) { e.preventDefault() openQuickActions() } else { @@ -136,9 +131,23 @@ function toggleDialog(e: Mousetrap.ExtendedKeyboardEvent) { } export function QuickActions() { - const items = useStore((state) => state.items) + const itemsBySource = useStore((state) => state.itemsBySource) const isOpen = useStore((state) => state.isOpen) + // Ambient items (global nav + breadcrumb ancestors) are computed inline + // rather than stored, so QuickActions never writes to the store it reads. + const globalItems = useGlobalActions() + const parentItems = useParentActions() + + // Flatten: page items first, then layout items, then breadcrumb ancestors, + // then global nav. Pages register after their parent layouts (a fact about + // React Router, I think), so reversing itemsBySource puts page-level items + // before layout-level items. + const items = useMemo( + () => [...itemsBySource.values()].reverse().flat().concat(parentItems, globalItems), + [itemsBySource, globalItems, parentItems] + ) + useKey('mod+k', toggleDialog, { global: true }) return ( diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 74e734a841..11f44e3bde 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useMemo, type ReactElement } from 'react' -import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { useLocation, type LoaderFunctionArgs } from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { @@ -53,7 +53,6 @@ export async function projectLayoutLoader({ params }: LoaderFunctionArgs) { } export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { - const navigate = useNavigate() // project will always be there, instance may not const projectSelector = useProjectSelector() const { data: project } = usePrefetchedQuery(projectView(projectSelector)) @@ -75,11 +74,12 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ + kind: 'link', navGroup: `Project '${project.name}'`, value: i.value, - onSelect: () => navigate(i.path), + to: i.path, })), - [pathname, navigate, project.name, projectSelector] + [pathname, project.name, projectSelector] ) ) diff --git a/app/layouts/SettingsLayout.tsx b/app/layouts/SettingsLayout.tsx index eb3238ca33..238048c511 100644 --- a/app/layouts/SettingsLayout.tsx +++ b/app/layouts/SettingsLayout.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useMemo } from 'react' -import { useLocation, useNavigate } from 'react-router' +import { useLocation } from 'react-router' import { AccessToken16Icon, @@ -27,7 +27,6 @@ import { ContentPane, PageContainer } from './helpers' export const handle = makeCrumb('Settings', pb.profile()) export default function SettingsLayout() { - const navigate = useNavigate() const { pathname } = useLocation() useQuickActions( @@ -41,11 +40,12 @@ export default function SettingsLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ + kind: 'link', navGroup: `Settings`, value: i.value, - onSelect: () => navigate(i.path), + to: i.path, })), - [pathname, navigate] + [pathname] ) ) diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 361727119a..429bfa5133 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useMemo } from 'react' -import { useLocation, useNavigate } from 'react-router' +import { useLocation } from 'react-router' import { Access16Icon, @@ -25,7 +25,6 @@ import { pb } from '~/util/path-builder' import { ContentPane, PageContainer } from './helpers' export default function SiloLayout() { - const navigate = useNavigate() const { pathname } = useLocation() const { me } = useCurrentUser() @@ -41,11 +40,12 @@ export default function SiloLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ + kind: 'link', navGroup: `Silo '${me.siloName}'`, value: i.value, - onSelect: () => navigate(i.path), + to: i.path, })), - [pathname, navigate, me.siloName] + [pathname, me.siloName] ) ) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 9faa528c98..d43684677c 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { useMemo } from 'react' -import { useLocation, useNavigate } from 'react-router' +import { useLocation } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { @@ -22,7 +22,7 @@ import { trigger404 } from '~/components/ErrorBoundary' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' import { useCurrentUser } from '~/hooks/use-current-user' -import { useQuickActions } from '~/hooks/use-quick-actions' +import { useQuickActions, type QuickActionItem } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { inventoryBase, pb } from '~/util/path-builder' @@ -44,7 +44,6 @@ export default function SystemLayout() { // robust way of doing this would be to make a separate layout for the // silo-specific routes in the route config, but it's overkill considering // this is a one-liner. Switch to that approach at the first sign of trouble. - const navigate = useNavigate() const { pathname } = useLocation() const { me } = useCurrentUser() @@ -61,18 +60,20 @@ export default function SystemLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ + kind: 'link' as const, navGroup: 'System', value: i.value, - onSelect: () => navigate(i.path), + to: i.path, })) - const backToSilo = { + const backToSilo: QuickActionItem = { + kind: 'link', navGroup: `Back to silo '${me.siloName}'`, value: 'Projects', - onSelect: () => navigate(pb.projects()), + to: pb.projects(), } return [...systemLinks, backToSilo] - }, [pathname, navigate, me.siloName]) + }, [pathname, me.siloName]) useQuickActions(actions) diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index cf6612329b..b2a7397b47 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -107,16 +107,18 @@ export default function ProjectsPage() { useMemo( () => [ { + kind: 'link', value: 'New project', - onSelect: () => navigate(pb.projectsNew()), + to: pb.projectsNew(), }, ...(allProjects?.items || []).map((p) => ({ + kind: 'link' as const, value: p.name, - onSelect: () => navigate(pb.project({ project: p.name })), + to: pb.project({ project: p.name }), navGroup: 'Go to project', })), ], - [navigate, allProjects] + [allProjects] ) ) diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 87ae0a5c2c..31936c2c77 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -169,6 +169,7 @@ export default function SiloAccessPage() { useMemo( () => [ { + kind: 'action', value: 'Add user or group', onSelect: () => setAddModalOpen(true), }, diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 7cc6f5e53a..5df5c2d3f6 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' -import { Outlet, useNavigate } from 'react-router' +import { Outlet } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type Image } from '@oxide/api' import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' @@ -96,22 +96,23 @@ export default function SiloImagesPage() { const columns = useColsWithActions(staticCols, makeActions) const { table } = useQueryTable({ query: imageList, columns, emptyState: }) - const navigate = useNavigate() const { data: allImages } = useQuery(q(api.imageList, { query: { limit: ALL_ISH } })) useQuickActions( useMemo( () => [ { + kind: 'action', value: 'Promote image', onSelect: () => setShowModal(true), }, ...(allImages?.items || []).map((i) => ({ + kind: 'link' as const, value: i.name, - onSelect: () => navigate(pb.siloImageEdit({ image: i.name })), + to: pb.siloImageEdit({ image: i.name }), navGroup: 'Go to silo image', })), ], - [navigate, allImages] + [allImages] ) ) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 254144480d..a17f5453fc 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -210,6 +210,7 @@ export default function ProjectAccessPage() { useMemo( () => [ { + kind: 'action', value: 'Add user or group', onSelect: () => setAddModalOpen(true), }, diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 1860bd0b7f..2f8187849c 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -8,7 +8,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { Outlet, type LoaderFunctionArgs } from 'react-router' import { api, @@ -139,22 +139,22 @@ export default function AffinityPage() { getCoreRowModel: getCoreRowModel(), }) - const navigate = useNavigate() useQuickActions( useMemo( () => [ { + kind: 'link', value: 'New anti-affinity group', - onSelect: () => navigate(pb.affinityNew({ project })), + to: pb.affinityNew({ project }), }, ...antiAffinityGroups.map((g) => ({ + kind: 'link' as const, value: g.name, - onSelect: () => - navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: g.name })), + to: pb.antiAffinityGroup({ project, antiAffinityGroup: g.name }), navGroup: 'Go to anti-affinity group', })), ], - [navigate, project, antiAffinityGroups] + [project, antiAffinityGroups] ) ) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 247259c1d4..2dc41fe6de 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { Outlet, type LoaderFunctionArgs } from 'react-router' import { api, @@ -189,7 +189,6 @@ export default function DisksPage() { emptyState: , }) - const navigate = useNavigate() const { data: allDisks } = useQuery( q(api.diskList, { query: { project, limit: ALL_ISH } }) ) @@ -197,16 +196,18 @@ export default function DisksPage() { useMemo( () => [ { + kind: 'link', value: 'New disk', - onSelect: () => navigate(pb.disksNew({ project })), + to: pb.disksNew({ project }), }, ...(allDisks?.items || []).map((d) => ({ + kind: 'link' as const, value: d.name, - onSelect: () => navigate(pb.disk({ project, disk: d.name })), + to: pb.disk({ project, disk: d.name }), navGroup: 'Go to disk', })), ], - [navigate, project, allDisks] + [project, allDisks] ) ) diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 120a73a282..fe5dab228f 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -220,16 +220,18 @@ export default function FloatingIpsPage() { useMemo( () => [ { + kind: 'link', value: 'New floating IP', - onSelect: () => navigate(pb.floatingIpsNew({ project })), + to: pb.floatingIpsNew({ project }), }, ...(allFips?.items || []).map((f) => ({ + kind: 'link' as const, value: f.name, - onSelect: () => navigate(pb.floatingIpEdit({ project, floatingIp: f.name })), + to: pb.floatingIpEdit({ project, floatingIp: f.name }), navGroup: 'Go to floating IP', })), ], - [project, navigate, allFips] + [project, allFips] ) ) diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 852fa60a00..4a7f4644b0 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' -import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { Outlet, type LoaderFunctionArgs } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type Image } from '@oxide/api' import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' @@ -109,7 +109,6 @@ export default function ImagesPage() { emptyState: , }) - const navigate = useNavigate() const { data: allImages } = useQuery( q(api.imageList, { query: { project, limit: ALL_ISH } }) ) @@ -117,16 +116,18 @@ export default function ImagesPage() { useMemo( () => [ { + kind: 'link', value: 'Upload image', - onSelect: () => navigate(pb.projectImagesNew({ project })), + to: pb.projectImagesNew({ project }), }, ...(allImages?.items || []).map((i) => ({ + kind: 'link' as const, value: i.name, - onSelect: () => navigate(pb.projectImageEdit({ project, image: i.name })), + to: pb.projectImageEdit({ project, image: i.name }), navGroup: 'Go to project image', })), ], - [project, navigate, allImages] + [project, allImages] ) ) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 9e5b22aa53..29b985157d 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -9,7 +9,7 @@ import { useQuery, type UseQueryOptions } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { filesize } from 'filesize' import { useMemo, useRef, useState } from 'react' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { type LoaderFunctionArgs } from 'react-router' import { api, @@ -198,21 +198,22 @@ export default function InstancesPage() { const { data: allInstances } = useQuery( q(api.instanceList, { query: { project, limit: ALL_ISH } }) ) - const navigate = useNavigate() useQuickActions( useMemo( () => [ { + kind: 'link', value: 'New instance', - onSelect: () => navigate(pb.instancesNew({ project })), + to: pb.instancesNew({ project }), }, ...(allInstances?.items || []).map((i) => ({ + kind: 'link' as const, value: i.name, - onSelect: () => navigate(pb.instance({ project, instance: i.name })), + to: pb.instance({ project, instance: i.name }), navGroup: 'Go to instance', })), ], - [project, allInstances, navigate] + [project, allInstances] ) ) diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index a9f30404a0..538cfcdf5e 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -170,11 +170,12 @@ export default function SnapshotsPage() { useMemo( () => [ { + kind: 'link', value: 'New snapshot', - onSelect: () => navigate(pb.snapshotsNew({ project })), + to: pb.snapshotsNew({ project }), }, ], - [navigate, project] + [project] ) ) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 99a3af503a..dfe501a541 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -140,16 +140,18 @@ export default function VpcsPage() { useMemo( () => [ { + kind: 'link', value: 'New VPC', - onSelect: () => navigate(pb.vpcsNew({ project })), + to: pb.vpcsNew({ project }), }, ...(allVpcs?.items || []).map((v) => ({ + kind: 'link' as const, value: v.name, - onSelect: () => navigate(pb.vpc({ project, vpc: v.name })), + to: pb.vpc({ project, vpc: v.name }), navGroup: 'Go to VPC', })), ], - [project, allVpcs, navigate] + [project, allVpcs] ) ) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index a908ccdd27..703be3faec 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -136,16 +136,18 @@ export default function IpPoolsPage() { useMemo( () => [ { + kind: 'link', value: 'New IP pool', - onSelect: () => navigate(pb.ipPoolsNew()), + to: pb.ipPoolsNew(), }, ...(allPools?.items || []).map((p) => ({ + kind: 'link' as const, value: p.name, - onSelect: () => navigate(pb.ipPool({ pool: p.name })), + to: pb.ipPool({ pool: p.name }), navGroup: 'Go to IP pool', })), ], - [navigate, allPools] + [allPools] ) ) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index e23c408385..f7521d03b4 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import { Outlet, useNavigate } from 'react-router' +import { Outlet } from 'react-router' import { api, getListQFn, q, queryClient, useApiMutation, type Silo } from '@oxide/api' import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' @@ -69,8 +69,6 @@ export async function clientLoader() { export const handle = makeCrumb('Silos', pb.silos()) export default function SilosPage() { - const navigate = useNavigate() - const { mutateAsync: deleteSilo } = useApiMutation(api.siloDelete, { onSuccess(_silo, { path }) { queryClient.invalidateEndpoint('siloList') @@ -103,14 +101,15 @@ export default function SilosPage() { useQuickActions( useMemo( () => [ - { value: 'New silo', onSelect: () => navigate(pb.silosNew()) }, + { kind: 'link', value: 'New silo', to: pb.silosNew() }, ...(allSilos?.items || []).map((o) => ({ + kind: 'link' as const, value: o.name, - onSelect: () => navigate(pb.silo({ silo: o.name })), + to: pb.silo({ silo: o.name }), navGroup: 'Go to silo', })), ], - [navigate, allSilos] + [allSilos] ) ) diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index a50e15ecc7..99a5183bef 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -9,6 +9,7 @@ import { Dialog as BaseDialog } from '@base-ui/react/dialog' import cn from 'classnames' import { matchSorter } from 'match-sorter' import { useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router' import { Close12Icon } from '@oxide/design-system/icons/react' @@ -19,13 +20,9 @@ import { classed } from '~/util/classed' import { DialogOverlay } from './DialogOverlay' import { useSteppedScroll } from './use-stepped-scroll' -export interface QuickActionItem { - value: string - // strings are paths to navigate() to - // onSelect: string | (() => void) - onSelect: () => void - navGroup?: string -} +export type QuickActionItem = + | { kind: 'link'; value: string; to: string; navGroup?: string } + | { kind: 'action'; value: string; onSelect: () => void; navGroup?: string } export interface ActionMenuProps { isOpen: boolean @@ -40,7 +37,13 @@ const LIST_HEIGHT = 384 const Outline = classed.div`absolute z-10 h-full w-full border border-accent pointer-events-none` +const liBase = + 'text-sans-md border-secondary box-border block h-full w-full cursor-pointer overflow-visible border select-none' +const liSelected = 'text-accent bg-accent hover:bg-accent-hover' +const liDefault = 'text-default bg-raise hover:bg-hover' + export function ActionMenu(props: ActionMenuProps) { + const navigate = useNavigate() const [input, setInput] = useState('') // TODO: filter by both nav group and item value const items = matchSorter(props.items, input, { @@ -67,6 +70,11 @@ export function ActionMenu(props: ActionMenuProps) { ...allGroups.map(([_, items]) => items) ) + // Map each item to its global index for selection tracking. We use this + // instead of comparing by value because values are not unique across owners. + const itemIndex = new Map() + itemsInOrder.forEach((item, i) => itemIndex.set(item, i)) + const [selectedIdx, setSelectedIdx] = useState(0) const selectedItem = itemsInOrder[selectedIdx] as QuickActionItem | undefined @@ -100,7 +108,11 @@ export function ActionMenu(props: ActionMenuProps) { const lastIdx = itemsInOrder.length - 1 if (e.key === KEYS.enter) { if (selectedItem) { - selectedItem.onSelect() + if (selectedItem.kind === 'action') { + selectedItem.onSelect() + } else { + navigate(selectedItem.to) + } e.preventDefault() onDismiss() } @@ -169,43 +181,61 @@ export function ActionMenu(props: ActionMenuProps) { style={{ maxHeight: LIST_HEIGHT }} >
    - {allGroups.map(([label, items]) => ( + {allGroups.map(([label, groupItems]) => (

    {label}

    - {items.map((item) => ( -
    - {item.value === selectedItem?.value && } - - {/* - TODO: there is probably a more correct way of fixing this reasonable lint error. - Putting a button inside the
  • is not a great solution because it becomes - focusable separate from the item selection - */} - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */} -
  • { + const idx = itemIndex.get(item)! + const isSelected = idx === selectedIdx + return ( +
    + {isSelected && } + {item.kind === 'link' ? ( +
  • + { + if (!e.metaKey && !e.ctrlKey && !e.shiftKey) { + onDismiss() + } + }} + > + {item.value} + +
  • + ) : ( + // Keyboard events handled by combobox div above + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
  • { + item.onSelect() + onDismiss() + }} + > + {item.value} +
  • )} - aria-selected={item.value === selectedItem?.value} - onClick={() => { - item.onSelect() - onDismiss() - }} - > - {item.value} - -
    - ))} +
    + ) + })} ))}
From e78c2cae3400b7045b203381ac956ba29225c93e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 17:19:57 -0500 Subject: [PATCH 05/10] a couple more tests --- test/e2e/action-menu.e2e.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/e2e/action-menu.e2e.ts b/test/e2e/action-menu.e2e.ts index 9bf6f60739..1a4471de73 100644 --- a/test/e2e/action-menu.e2e.ts +++ b/test/e2e/action-menu.e2e.ts @@ -91,6 +91,31 @@ test('"Go up" actions derived from breadcrumbs', async ({ page }) => { await expect(page).toHaveURL('/projects') }) +test('Enter navigates to selected item', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + // search for a specific instance and hit Enter + await page.keyboard.type('db1') + await expect(getSelectedItem(page)).toHaveText('db1') + await page.keyboard.press('Enter') + await expect(page).toHaveURL('/projects/mock-project/instances/db1/storage') +}) + +test('items from page and layout sources coexist', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await openActionMenu(page) + + // page-level actions (from InstancesPage) + await expect(page.getByRole('option', { name: 'New instance' })).toBeVisible() + // page-level "Go to" group (from InstancesPage) + await expect(page.getByText('Go to instance')).toBeVisible() + await expect(page.getByRole('option', { name: 'db1' })).toBeVisible() + // layout-level group (from ProjectLayoutBase) + await expect(page.getByText("Project 'mock-project'")).toBeVisible() + await expect(page.getByRole('option', { name: 'Disks' })).toBeVisible() +}) + test('dismiss with Escape', async ({ page }) => { await page.goto('/projects/mock-project/instances') await openActionMenu(page) From a038004601fd1523bfb01a8d8227411e1d35e747 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 17:50:08 -0500 Subject: [PATCH 06/10] make navGroup required, improve test flake --- app/pages/ProjectsPage.tsx | 1 + app/pages/SiloAccessPage.tsx | 1 + app/pages/SiloImagesPage.tsx | 1 + .../project/access/ProjectAccessPage.tsx | 1 + app/pages/project/affinity/AffinityPage.tsx | 1 + app/pages/project/disks/DisksPage.tsx | 1 + .../project/floating-ips/FloatingIpsPage.tsx | 1 + app/pages/project/images/ImagesPage.tsx | 1 + app/pages/project/instances/InstancesPage.tsx | 1 + app/pages/project/snapshots/SnapshotsPage.tsx | 1 + app/pages/project/vpcs/VpcsPage.tsx | 1 + app/pages/system/networking/IpPoolsPage.tsx | 1 + app/pages/system/silos/SilosPage.tsx | 2 +- app/ui/lib/ActionMenu.tsx | 45 ++++++++----------- test/e2e/action-menu.e2e.ts | 12 +++-- 15 files changed, 39 insertions(+), 32 deletions(-) diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index b2a7397b47..2e1e6972c8 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -109,6 +109,7 @@ export default function ProjectsPage() { { kind: 'link', value: 'New project', + navGroup: 'Actions', to: pb.projectsNew(), }, ...(allProjects?.items || []).map((p) => ({ diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 31936c2c77..3aa7de1251 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -171,6 +171,7 @@ export default function SiloAccessPage() { { kind: 'action', value: 'Add user or group', + navGroup: 'Actions', onSelect: () => setAddModalOpen(true), }, ], diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 5df5c2d3f6..867b851e33 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -103,6 +103,7 @@ export default function SiloImagesPage() { { kind: 'action', value: 'Promote image', + navGroup: 'Actions', onSelect: () => setShowModal(true), }, ...(allImages?.items || []).map((i) => ({ diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index a17f5453fc..d763235a78 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -212,6 +212,7 @@ export default function ProjectAccessPage() { { kind: 'action', value: 'Add user or group', + navGroup: 'Actions', onSelect: () => setAddModalOpen(true), }, ], diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 2f8187849c..017a93b164 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -145,6 +145,7 @@ export default function AffinityPage() { { kind: 'link', value: 'New anti-affinity group', + navGroup: 'Actions', to: pb.affinityNew({ project }), }, ...antiAffinityGroups.map((g) => ({ diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 2dc41fe6de..a5c1fce93b 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -198,6 +198,7 @@ export default function DisksPage() { { kind: 'link', value: 'New disk', + navGroup: 'Actions', to: pb.disksNew({ project }), }, ...(allDisks?.items || []).map((d) => ({ diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index fe5dab228f..cb35468158 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -222,6 +222,7 @@ export default function FloatingIpsPage() { { kind: 'link', value: 'New floating IP', + navGroup: 'Actions', to: pb.floatingIpsNew({ project }), }, ...(allFips?.items || []).map((f) => ({ diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 4a7f4644b0..330a73b29c 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -118,6 +118,7 @@ export default function ImagesPage() { { kind: 'link', value: 'Upload image', + navGroup: 'Actions', to: pb.projectImagesNew({ project }), }, ...(allImages?.items || []).map((i) => ({ diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 29b985157d..726ebd3232 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -204,6 +204,7 @@ export default function InstancesPage() { { kind: 'link', value: 'New instance', + navGroup: 'Actions', to: pb.instancesNew({ project }), }, ...(allInstances?.items || []).map((i) => ({ diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 538cfcdf5e..d4d468d5a5 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -172,6 +172,7 @@ export default function SnapshotsPage() { { kind: 'link', value: 'New snapshot', + navGroup: 'Actions', to: pb.snapshotsNew({ project }), }, ], diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index dfe501a541..5e6c086039 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -142,6 +142,7 @@ export default function VpcsPage() { { kind: 'link', value: 'New VPC', + navGroup: 'Actions', to: pb.vpcsNew({ project }), }, ...(allVpcs?.items || []).map((v) => ({ diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 703be3faec..c2abaabef0 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -138,6 +138,7 @@ export default function IpPoolsPage() { { kind: 'link', value: 'New IP pool', + navGroup: 'Actions', to: pb.ipPoolsNew(), }, ...(allPools?.items || []).map((p) => ({ diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index f7521d03b4..0d6278b645 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -101,7 +101,7 @@ export default function SilosPage() { useQuickActions( useMemo( () => [ - { kind: 'link', value: 'New silo', to: pb.silosNew() }, + { kind: 'link', value: 'New silo', navGroup: 'Actions', to: pb.silosNew() }, ...(allSilos?.items || []).map((o) => ({ kind: 'link' as const, value: o.name, diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index 99a5183bef..dd1070e2dc 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -10,19 +10,19 @@ import cn from 'classnames' import { matchSorter } from 'match-sorter' import { useRef, useState } from 'react' import { Link, useNavigate } from 'react-router' +import * as R from 'remeda' import { Close12Icon } from '@oxide/design-system/icons/react' import { KEYS } from '~/ui/util/keys' -import { groupBy } from '~/util/array' import { classed } from '~/util/classed' import { DialogOverlay } from './DialogOverlay' import { useSteppedScroll } from './use-stepped-scroll' export type QuickActionItem = - | { kind: 'link'; value: string; to: string; navGroup?: string } - | { kind: 'action'; value: string; onSelect: () => void; navGroup?: string } + | { kind: 'link'; value: string; to: string; navGroup: string } + | { kind: 'action'; value: string; onSelect: () => void; navGroup: string } export interface ActionMenuProps { isOpen: boolean @@ -52,31 +52,19 @@ export function ActionMenu(props: ActionMenuProps) { baseSort: (a, b) => (a.index < b.index ? -1 : 1), }) - // items without a navGroup label are considered actions and rendered first - const actions = items.filter((i) => !i.navGroup) - - // TODO: repent. this is horrible - const groupedItems = groupBy( - items.filter((i) => i.navGroup), - (i) => i.navGroup! + const allGroups = R.pipe( + items, + R.groupBy((i) => i.navGroup), + R.entries(), + // "Actions" first so page-level create/add actions are always at the top. + // Other sorting by group that we've already done in the calling code is + // preserved. + R.sortBy(([key]) => key !== 'Actions') ) - - const allGroups: [string, QuickActionItem[]][] = - actions.length > 0 - ? [['Actions', items.filter((i) => !i.navGroup)], ...groupedItems] - : groupedItems - - const itemsInOrder = ([] as QuickActionItem[]).concat( - ...allGroups.map(([_, items]) => items) - ) - - // Map each item to its global index for selection tracking. We use this - // instead of comparing by value because values are not unique across owners. - const itemIndex = new Map() - itemsInOrder.forEach((item, i) => itemIndex.set(item, i)) + const itemsInOrder = allGroups.flatMap(([_, items]) => items) const [selectedIdx, setSelectedIdx] = useState(0) - const selectedItem = itemsInOrder[selectedIdx] as QuickActionItem | undefined + const selectedItem = itemsInOrder.at(selectedIdx) const divRef = useRef(null) const ulRef = useRef(null) @@ -187,10 +175,13 @@ export function ActionMenu(props: ActionMenuProps) { {label} {groupItems.map((item) => { - const idx = itemIndex.get(item)! + const idx = itemsInOrder.indexOf(item) const isSelected = idx === selectedIdx return ( -
+
{isSelected && } {item.kind === 'link' ? (
  • { await page.goto('/projects/mock-project/instances') await openActionMenu(page) + // wait for instance list to load so the items don't shift mid-test + await expect(page.getByRole('option', { name: 'db1' })).toBeVisible() + const selected = getSelectedItem(page) // first item is selected by default await expect(selected).toHaveText('New instance') // Ctrl+N moves down await page.keyboard.press('Control+n') - await expect(selected).not.toHaveText('New instance') - const secondItem = await selected.textContent() + await expect(selected).toHaveText('db1') // Ctrl+P moves back up await page.keyboard.press('Control+p') @@ -47,18 +49,20 @@ test('Ctrl+N/P navigate up and down', async ({ page }) => { // Ctrl+N again gets the same second item await page.keyboard.press('Control+n') - await expect(selected).toHaveText(secondItem!) + await expect(selected).toHaveText('db1') }) test('Arrow keys navigate up and down', async ({ page }) => { await page.goto('/projects/mock-project/instances') await openActionMenu(page) + await expect(page.getByRole('option', { name: 'db1' })).toBeVisible() + const selected = getSelectedItem(page) await expect(selected).toHaveText('New instance') await page.keyboard.press('ArrowDown') - await expect(selected).not.toHaveText('New instance') + await expect(selected).toHaveText('db1') await page.keyboard.press('ArrowUp') await expect(selected).toHaveText('New instance') From 88ce723c9d75bd5cae22c51ec199653806b4d308 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 18:01:09 -0500 Subject: [PATCH 07/10] QuickActionItem doesn't need to be a discriminated union --- app/hooks/use-quick-actions.tsx | 9 +++---- app/layouts/ProjectLayoutBase.tsx | 3 +-- app/layouts/SettingsLayout.tsx | 3 +-- app/layouts/SiloLayout.tsx | 3 +-- app/layouts/SystemLayout.tsx | 6 ++--- app/pages/ProjectsPage.tsx | 6 ++--- app/pages/SiloAccessPage.tsx | 3 +-- app/pages/SiloImagesPage.tsx | 6 ++--- .../project/access/ProjectAccessPage.tsx | 3 +-- app/pages/project/affinity/AffinityPage.tsx | 6 ++--- app/pages/project/disks/DisksPage.tsx | 6 ++--- .../project/floating-ips/FloatingIpsPage.tsx | 6 ++--- app/pages/project/images/ImagesPage.tsx | 6 ++--- app/pages/project/instances/InstancesPage.tsx | 6 ++--- app/pages/project/snapshots/SnapshotsPage.tsx | 3 +-- app/pages/project/vpcs/VpcsPage.tsx | 6 ++--- app/pages/system/networking/IpPoolsPage.tsx | 6 ++--- app/pages/system/silos/SilosPage.tsx | 5 ++-- app/ui/lib/ActionMenu.tsx | 24 +++++++++++-------- 19 files changed, 45 insertions(+), 71 deletions(-) diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index 2900083fc2..5848eab71a 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -68,18 +68,16 @@ function useGlobalActions(): QuickActionItem[] { const actions: QuickActionItem[] = [] if (me.fleetViewer && !location.pathname.startsWith('/system/')) { actions.push({ - kind: 'link', navGroup: 'System', value: 'Manage system', - to: pb.silos(), + action: pb.silos(), }) } if (!location.pathname.startsWith('/settings/')) { actions.push({ - kind: 'link', navGroup: 'User', value: 'Settings', - to: pb.profile(), + action: pb.profile(), }) } return actions @@ -95,9 +93,8 @@ function useParentActions(): QuickActionItem[] { // Everything except the last crumb (the current page) const parentCrumbs = navCrumbs.slice(0, -1) return parentCrumbs.map((c) => ({ - kind: 'link', value: c.label, - to: c.path, + action: c.path, navGroup: 'Go up', })) }, [crumbs]) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 11f44e3bde..c021b08aef 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -74,10 +74,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ - kind: 'link', navGroup: `Project '${project.name}'`, value: i.value, - to: i.path, + action: i.path, })), [pathname, project.name, projectSelector] ) diff --git a/app/layouts/SettingsLayout.tsx b/app/layouts/SettingsLayout.tsx index 238048c511..649e2d1595 100644 --- a/app/layouts/SettingsLayout.tsx +++ b/app/layouts/SettingsLayout.tsx @@ -40,10 +40,9 @@ export default function SettingsLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ - kind: 'link', navGroup: `Settings`, value: i.value, - to: i.path, + action: i.path, })), [pathname] ) diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 429bfa5133..d070765115 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -40,10 +40,9 @@ export default function SiloLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ - kind: 'link', navGroup: `Silo '${me.siloName}'`, value: i.value, - to: i.path, + action: i.path, })), [pathname, me.siloName] ) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index d43684677c..05c0e74345 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -60,17 +60,15 @@ export default function SystemLayout() { // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) .map((i) => ({ - kind: 'link' as const, navGroup: 'System', value: i.value, - to: i.path, + action: i.path, })) const backToSilo: QuickActionItem = { - kind: 'link', navGroup: `Back to silo '${me.siloName}'`, value: 'Projects', - to: pb.projects(), + action: pb.projects(), } return [...systemLinks, backToSilo] }, [pathname, me.siloName]) diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 2e1e6972c8..9fdd256f42 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -107,15 +107,13 @@ export default function ProjectsPage() { useMemo( () => [ { - kind: 'link', value: 'New project', navGroup: 'Actions', - to: pb.projectsNew(), + action: pb.projectsNew(), }, ...(allProjects?.items || []).map((p) => ({ - kind: 'link' as const, value: p.name, - to: pb.project({ project: p.name }), + action: pb.project({ project: p.name }), navGroup: 'Go to project', })), ], diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 3aa7de1251..c93b19a02b 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -169,10 +169,9 @@ export default function SiloAccessPage() { useMemo( () => [ { - kind: 'action', value: 'Add user or group', navGroup: 'Actions', - onSelect: () => setAddModalOpen(true), + action: () => setAddModalOpen(true), }, ], [] diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index 867b851e33..e0209d1301 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -101,15 +101,13 @@ export default function SiloImagesPage() { useMemo( () => [ { - kind: 'action', value: 'Promote image', navGroup: 'Actions', - onSelect: () => setShowModal(true), + action: () => setShowModal(true), }, ...(allImages?.items || []).map((i) => ({ - kind: 'link' as const, value: i.name, - to: pb.siloImageEdit({ image: i.name }), + action: pb.siloImageEdit({ image: i.name }), navGroup: 'Go to silo image', })), ], diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index d763235a78..f99c926ff3 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -210,10 +210,9 @@ export default function ProjectAccessPage() { useMemo( () => [ { - kind: 'action', value: 'Add user or group', navGroup: 'Actions', - onSelect: () => setAddModalOpen(true), + action: () => setAddModalOpen(true), }, ], [] diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 017a93b164..d15568910a 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -143,15 +143,13 @@ export default function AffinityPage() { useMemo( () => [ { - kind: 'link', value: 'New anti-affinity group', navGroup: 'Actions', - to: pb.affinityNew({ project }), + action: pb.affinityNew({ project }), }, ...antiAffinityGroups.map((g) => ({ - kind: 'link' as const, value: g.name, - to: pb.antiAffinityGroup({ project, antiAffinityGroup: g.name }), + action: pb.antiAffinityGroup({ project, antiAffinityGroup: g.name }), navGroup: 'Go to anti-affinity group', })), ], diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index a5c1fce93b..b9acc3627d 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -196,15 +196,13 @@ export default function DisksPage() { useMemo( () => [ { - kind: 'link', value: 'New disk', navGroup: 'Actions', - to: pb.disksNew({ project }), + action: pb.disksNew({ project }), }, ...(allDisks?.items || []).map((d) => ({ - kind: 'link' as const, value: d.name, - to: pb.disk({ project, disk: d.name }), + action: pb.disk({ project, disk: d.name }), navGroup: 'Go to disk', })), ], diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index cb35468158..6ffea50fe7 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -220,15 +220,13 @@ export default function FloatingIpsPage() { useMemo( () => [ { - kind: 'link', value: 'New floating IP', navGroup: 'Actions', - to: pb.floatingIpsNew({ project }), + action: pb.floatingIpsNew({ project }), }, ...(allFips?.items || []).map((f) => ({ - kind: 'link' as const, value: f.name, - to: pb.floatingIpEdit({ project, floatingIp: f.name }), + action: pb.floatingIpEdit({ project, floatingIp: f.name }), navGroup: 'Go to floating IP', })), ], diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 330a73b29c..b464a48c22 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -116,15 +116,13 @@ export default function ImagesPage() { useMemo( () => [ { - kind: 'link', value: 'Upload image', navGroup: 'Actions', - to: pb.projectImagesNew({ project }), + action: pb.projectImagesNew({ project }), }, ...(allImages?.items || []).map((i) => ({ - kind: 'link' as const, value: i.name, - to: pb.projectImageEdit({ project, image: i.name }), + action: pb.projectImageEdit({ project, image: i.name }), navGroup: 'Go to project image', })), ], diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 726ebd3232..8e9080eb5c 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -202,15 +202,13 @@ export default function InstancesPage() { useMemo( () => [ { - kind: 'link', value: 'New instance', navGroup: 'Actions', - to: pb.instancesNew({ project }), + action: pb.instancesNew({ project }), }, ...(allInstances?.items || []).map((i) => ({ - kind: 'link' as const, value: i.name, - to: pb.instance({ project, instance: i.name }), + action: pb.instance({ project, instance: i.name }), navGroup: 'Go to instance', })), ], diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index d4d468d5a5..ceeefe16de 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -170,10 +170,9 @@ export default function SnapshotsPage() { useMemo( () => [ { - kind: 'link', value: 'New snapshot', navGroup: 'Actions', - to: pb.snapshotsNew({ project }), + action: pb.snapshotsNew({ project }), }, ], [project] diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 5e6c086039..90ab42fd98 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -140,15 +140,13 @@ export default function VpcsPage() { useMemo( () => [ { - kind: 'link', value: 'New VPC', navGroup: 'Actions', - to: pb.vpcsNew({ project }), + action: pb.vpcsNew({ project }), }, ...(allVpcs?.items || []).map((v) => ({ - kind: 'link' as const, value: v.name, - to: pb.vpc({ project, vpc: v.name }), + action: pb.vpc({ project, vpc: v.name }), navGroup: 'Go to VPC', })), ], diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index c2abaabef0..ec4c5432ea 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -136,15 +136,13 @@ export default function IpPoolsPage() { useMemo( () => [ { - kind: 'link', value: 'New IP pool', navGroup: 'Actions', - to: pb.ipPoolsNew(), + action: pb.ipPoolsNew(), }, ...(allPools?.items || []).map((p) => ({ - kind: 'link' as const, value: p.name, - to: pb.ipPool({ pool: p.name }), + action: pb.ipPool({ pool: p.name }), navGroup: 'Go to IP pool', })), ], diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 0d6278b645..a560e5d818 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -101,11 +101,10 @@ export default function SilosPage() { useQuickActions( useMemo( () => [ - { kind: 'link', value: 'New silo', navGroup: 'Actions', to: pb.silosNew() }, + { value: 'New silo', navGroup: 'Actions', action: pb.silosNew() }, ...(allSilos?.items || []).map((o) => ({ - kind: 'link' as const, value: o.name, - to: pb.silo({ silo: o.name }), + action: pb.silo({ silo: o.name }), navGroup: 'Go to silo', })), ], diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index dd1070e2dc..55e1027af5 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -20,9 +20,12 @@ import { classed } from '~/util/classed' import { DialogOverlay } from './DialogOverlay' import { useSteppedScroll } from './use-stepped-scroll' -export type QuickActionItem = - | { kind: 'link'; value: string; to: string; navGroup: string } - | { kind: 'action'; value: string; onSelect: () => void; navGroup: string } +export type QuickActionItem = { + value: string + navGroup: string + /** Path string to navigate to or callback to invoke */ + action: string | (() => void) +} export interface ActionMenuProps { isOpen: boolean @@ -96,10 +99,10 @@ export function ActionMenu(props: ActionMenuProps) { const lastIdx = itemsInOrder.length - 1 if (e.key === KEYS.enter) { if (selectedItem) { - if (selectedItem.kind === 'action') { - selectedItem.onSelect() + if (typeof selectedItem.action === 'function') { + selectedItem.action() } else { - navigate(selectedItem.to) + navigate(selectedItem.action) } e.preventDefault() onDismiss() @@ -171,19 +174,20 @@ export function ActionMenu(props: ActionMenuProps) {
      {allGroups.map(([label, groupItems]) => (
      -

      +

      {label}

      {groupItems.map((item) => { const idx = itemsInOrder.indexOf(item) const isSelected = idx === selectedIdx + const { action } = item return (
      {isSelected && } - {item.kind === 'link' ? ( + {typeof action === 'string' ? (
    • { @@ -217,7 +221,7 @@ export function ActionMenu(props: ActionMenuProps) { )} aria-selected={isSelected} onClick={() => { - item.onSelect() + action() onDismiss() }} > From 14b4b3b4a07cd4a78011ff4abe8ac30eb543b577 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 18:14:44 -0500 Subject: [PATCH 08/10] don't do indexOf though --- app/ui/lib/ActionMenu.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index 55e1027af5..ff4019b596 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -65,6 +65,8 @@ export function ActionMenu(props: ActionMenuProps) { R.sortBy(([key]) => key !== 'Actions') ) const itemsInOrder = allGroups.flatMap(([_, items]) => items) + // Map each item to its global index for O(1) lookup during render + const itemIndex = new Map(itemsInOrder.map((item, i) => [item, i])) const [selectedIdx, setSelectedIdx] = useState(0) const selectedItem = itemsInOrder.at(selectedIdx) @@ -178,7 +180,7 @@ export function ActionMenu(props: ActionMenuProps) { {label} {groupItems.map((item) => { - const idx = itemsInOrder.indexOf(item) + const idx = itemIndex.get(item)! const isSelected = idx === selectedIdx const { action } = item return ( From 0c057ed20a0c87b91c37586c02c6c553660a205c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 18:19:06 -0500 Subject: [PATCH 09/10] try to improve test flake again --- test/e2e/action-menu.e2e.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/action-menu.e2e.ts b/test/e2e/action-menu.e2e.ts index 9094221324..6f1aa9f464 100644 --- a/test/e2e/action-menu.e2e.ts +++ b/test/e2e/action-menu.e2e.ts @@ -21,7 +21,8 @@ const getSelectedItem = (page: Page) => page.getByRole('option', { selected: tru test('Enter key does not prematurely submit a linked form', async ({ page }) => { await page.goto('/system/silos') await openActionMenu(page) - // "New silo" is the first item in the list, so we can just hit enter to open the modal + // wait for page-level quick actions to register before pressing Enter + await expect(page.getByRole('option', { name: 'New silo' })).toBeVisible() await page.keyboard.press('Enter') await expect(page.getByRole('dialog', { name: 'Create silo' })).toBeVisible() // make sure error text is not visible @@ -72,6 +73,9 @@ test('search filters items', async ({ page }) => { await page.goto('/projects/mock-project/instances') await openActionMenu(page) + // wait for instance list items to load before searching + await expect(page.getByRole('option', { name: 'db1' })).toBeVisible() + // type a search query that matches a specific instance await page.keyboard.type('db1') // the matching item should be visible From 619fd5e4513e0ea3c30189bec0e3a478735f510c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 17 Mar 2026 19:00:47 -0500 Subject: [PATCH 10/10] autoFocus quick actions input (another attempt to fix test flake) --- app/ui/lib/ActionMenu.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/ui/lib/ActionMenu.tsx b/app/ui/lib/ActionMenu.tsx index ff4019b596..90b2d9d8de 100644 --- a/app/ui/lib/ActionMenu.tsx +++ b/app/ui/lib/ActionMenu.tsx @@ -130,6 +130,12 @@ export function ActionMenu(props: ActionMenuProps) { )} >