Skip to content

Commit bde9b1d

Browse files
authored
Quick actions refactor + new features (#3131)
#3129 inspired me to add a couple more features to the quick actions menu, which in turn inspired a big refactor to avoid problems with possible duplicate entries. I also added a bunch of e2e tests that we probably should have had before. ### New features - `ctrl+n`/`ctrl+p` keyboard navigation (in addition to arrow keys) - "Go up" actions derived from breadcrumbs, so you can quickly navigate to parent pages - Links are real links, which means you can middle click them if you insist https://github.com/user-attachments/assets/d40d4d80-88b4-41e6-a41b-e7274520fa11 ### Refactor The old store held a flat list of `QuickActionItem`. https://github.com/oxidecomputer/console/blob/751a80e64b32bacbfc22379e70c24383685cb853/app/ui/lib/ActionMenu.tsx#L22-L28 `value` there is the text label displayed in the UI and (problematically) is also meant to uniquely identify the item. When calling pages (e.g., the project layout and the instance list page) called `useQuickActions` with a list of items, `useQuickActions` called `addActions`, which deduped the list of items based on `value`. The problem is that items with the same `value` in different groups could collide — removing by value would remove items you didn't meant to remove. We had an `invariant` call making sure the values were unique, but this is actually pretty bad in hindsight because it's probably possible for a user to name something to create a duplicate. (In practice we probably avoided this because all user-settable names are lower-case and all our nav items have upper case letters.) In any case, the new breadcrumbs feature added a new source of items that could potentially be a duplicate with something else in the list. That is what prompted me to get rid of the deduping mechanism altogether. The new store is a `Map<string, QuickActionItem[]>` keyed by an ID for each calling page, so registration is just `map.set(id, items)` and cleanup is `map.delete(id)`. Deduping is unnecessary because each source component owns its own slot. In theory each source is responsible for deduping its items, but the way the item lists are constructed, this is never really a problem. And if a duplicate sneaks in, it doesn't actually matter. Finally, since I was already messing everything up, I changed `QuickActionItem` to take both link and callback items, like we do for menu items. They're almost all links — only a few modal-opening actions are not.
1 parent f57bb8a commit bde9b1d

File tree

20 files changed

+348
-184
lines changed

20 files changed

+348
-184
lines changed

app/hooks/use-quick-actions.tsx

Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,51 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { useEffect, useMemo } from 'react'
9-
import { useLocation, useNavigate } from 'react-router'
8+
import { useEffect, useId, useMemo } from 'react'
9+
import { useLocation } from 'react-router'
1010
import { create } from 'zustand'
1111

12+
import { useCrumbs } from '~/hooks/use-crumbs'
1213
import { useCurrentUser } from '~/hooks/use-current-user'
1314
import { ActionMenu, type QuickActionItem } from '~/ui/lib/ActionMenu'
14-
import { invariant } from '~/util/invariant'
1515
import { pb } from '~/util/path-builder'
1616

1717
import { useKey } from './use-key'
1818

19-
type Items = QuickActionItem[]
19+
export type { QuickActionItem }
2020

2121
type StoreState = {
22-
items: Items
22+
// Multiple useQuickActions() hooks are active simultaneously — e.g., a
23+
// layout registers "Create project" while a nested page registers "Create
24+
// instance." Each caller is called a source and gets its own slot keyed by
25+
// React useId(), so when a page unmounts, its cleanup removes only its items,
26+
// leaving the layout's items intact. The map is flattened into a single list
27+
// at render time.
28+
itemsBySource: Map<string, QuickActionItem[]>
2329
isOpen: boolean
2430
}
2531

26-
// TODO: dedupe by group and value together so we can have, e.g., both silo and
27-
// system utilization at the same time
28-
29-
// removeByValue dedupes items so they can be added as many times as we want
30-
// without appearing in the menu multiple times
31-
const removeByValue = (items: Items, toRemove: Items) => {
32-
const valuesToRemove = new Set(toRemove.map((i) => i.value))
33-
return items.filter((i) => !valuesToRemove.has(i.value))
34-
}
35-
36-
const useStore = create<StoreState>(() => ({ items: [], isOpen: false }))
32+
const useStore = create<StoreState>(() => ({ itemsBySource: new Map(), isOpen: false }))
3733

3834
// zustand docs say it's fine not to put your setters in the store
3935
// https://github.com/pmndrs/zustand/blob/0426978/docs/guides/practice-with-no-store-actions.md
4036

41-
function addActions(toAdd: Items) {
42-
useStore.setState(({ items }) => ({ items: removeByValue(items, toAdd).concat(toAdd) }))
37+
// These create a new Map each time because zustand uses reference equality to
38+
// detect changes — mutating in place wouldn't trigger a re-render.
39+
function setSourceItems(sourceId: string, items: QuickActionItem[]) {
40+
useStore.setState(({ itemsBySource }) => {
41+
const next = new Map(itemsBySource)
42+
next.set(sourceId, items)
43+
return { itemsBySource: next }
44+
})
4345
}
4446

45-
function removeActions(toRemove: Items) {
46-
useStore.setState(({ items }) => ({ items: removeByValue(items, toRemove) }))
47+
function clearSource(sourceId: string) {
48+
useStore.setState(({ itemsBySource }) => {
49+
const next = new Map(itemsBySource)
50+
next.delete(sourceId)
51+
return { itemsBySource: next }
52+
})
4753
}
4854

4955
export function openQuickActions() {
@@ -54,61 +60,66 @@ function closeQuickActions() {
5460
useStore.setState({ isOpen: false })
5561
}
5662

57-
function useGlobalActions() {
63+
function useGlobalActions(): QuickActionItem[] {
5864
const location = useLocation()
59-
const navigate = useNavigate()
6065
const { me } = useCurrentUser()
6166

6267
return useMemo(() => {
63-
const actions = []
64-
// only add settings link if we're not on a settings page
65-
if (!location.pathname.startsWith('/settings/')) {
66-
actions.push({
67-
navGroup: 'User',
68-
value: 'Settings',
69-
onSelect: () => navigate(pb.profile()),
70-
})
71-
}
68+
const actions: QuickActionItem[] = []
7269
if (me.fleetViewer && !location.pathname.startsWith('/system/')) {
7370
actions.push({
7471
navGroup: 'System',
7572
value: 'Manage system',
76-
onSelect: () => navigate(pb.silos()),
73+
action: pb.silos(),
74+
})
75+
}
76+
if (!location.pathname.startsWith('/settings/')) {
77+
actions.push({
78+
navGroup: 'User',
79+
value: 'Settings',
80+
action: pb.profile(),
7781
})
7882
}
7983
return actions
80-
}, [location.pathname, navigate, me.fleetViewer])
84+
}, [location.pathname, me.fleetViewer])
85+
}
86+
87+
/** Derive "Go up" actions from breadcrumb ancestors of the current page */
88+
function useParentActions(): QuickActionItem[] {
89+
const crumbs = useCrumbs()
90+
91+
return useMemo(() => {
92+
const navCrumbs = crumbs.filter((c) => !c.titleOnly)
93+
// Everything except the last crumb (the current page)
94+
const parentCrumbs = navCrumbs.slice(0, -1)
95+
return parentCrumbs.map((c) => ({
96+
value: c.label,
97+
action: c.path,
98+
navGroup: 'Go up',
99+
}))
100+
}, [crumbs])
81101
}
82102

83103
/**
84-
* Register action items with the global quick actions menu. `itemsToAdd` must
104+
* Register action items with the global quick actions menu. `items` must
85105
* be memoized by the caller, otherwise the effect will run too often.
86106
*
87-
* The idea here is that any component in the tree can register actions to go in
88-
* the menu and having them appear when the component is mounted and not appear
89-
* when the component is unmounted Just Works.
107+
* Each component instance gets its own source slot (via useId), so items are
108+
* cleaned up automatically when the component unmounts without affecting
109+
* other sources' registrations.
90110
*/
91-
export function useQuickActions(itemsToAdd: QuickActionItem[]) {
92-
const location = useLocation()
93-
94-
// Add routes without declaring them in every `useQuickActions` call
95-
const globalItems = useGlobalActions()
111+
export function useQuickActions(items: QuickActionItem[]) {
112+
const sourceId = useId()
96113

97114
useEffect(() => {
98-
const allItems = [...itemsToAdd, ...globalItems]
99-
invariant(
100-
allItems.length === new Set(allItems.map((i) => i.value)).size,
101-
'Items being added to the list of quick actions must have unique `value` values.'
102-
)
103-
addActions(allItems)
104-
return () => removeActions(allItems)
105-
}, [itemsToAdd, globalItems, location.pathname])
115+
setSourceItems(sourceId, items)
116+
return () => clearSource(sourceId)
117+
}, [sourceId, items])
106118
}
107119

108120
function toggleDialog(e: Mousetrap.ExtendedKeyboardEvent) {
109-
const { items, isOpen } = useStore.getState()
110-
111-
if (items.length > 0 && !isOpen) {
121+
const { itemsBySource, isOpen } = useStore.getState()
122+
if (itemsBySource.size > 0 && !isOpen) {
112123
e.preventDefault()
113124
openQuickActions()
114125
} else {
@@ -117,9 +128,23 @@ function toggleDialog(e: Mousetrap.ExtendedKeyboardEvent) {
117128
}
118129

119130
export function QuickActions() {
120-
const items = useStore((state) => state.items)
131+
const itemsBySource = useStore((state) => state.itemsBySource)
121132
const isOpen = useStore((state) => state.isOpen)
122133

134+
// Ambient items (global nav + breadcrumb ancestors) are computed inline
135+
// rather than stored, so QuickActions never writes to the store it reads.
136+
const globalItems = useGlobalActions()
137+
const parentItems = useParentActions()
138+
139+
// Flatten: page items first, then layout items, then breadcrumb ancestors,
140+
// then global nav. Pages register after their parent layouts (a fact about
141+
// React Router, I think), so reversing itemsBySource puts page-level items
142+
// before layout-level items.
143+
const items = useMemo(
144+
() => [...itemsBySource.values()].reverse().flat().concat(parentItems, globalItems),
145+
[itemsBySource, globalItems, parentItems]
146+
)
147+
123148
useKey('mod+k', toggleDialog, { global: true })
124149

125150
return (

app/layouts/ProjectLayoutBase.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useMemo, type ReactElement } from 'react'
9-
import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router'
9+
import { useLocation, type LoaderFunctionArgs } from 'react-router'
1010

1111
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
1212
import {
@@ -53,7 +53,6 @@ export async function projectLayoutLoader({ params }: LoaderFunctionArgs) {
5353
}
5454

5555
export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
56-
const navigate = useNavigate()
5756
// project will always be there, instance may not
5857
const projectSelector = useProjectSelector()
5958
const { data: project } = usePrefetchedQuery(projectView(projectSelector))
@@ -77,9 +76,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
7776
.map((i) => ({
7877
navGroup: `Project '${project.name}'`,
7978
value: i.value,
80-
onSelect: () => navigate(i.path),
79+
action: i.path,
8180
})),
82-
[pathname, navigate, project.name, projectSelector]
81+
[pathname, project.name, projectSelector]
8382
)
8483
)
8584

app/layouts/SettingsLayout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useMemo } from 'react'
9-
import { useLocation, useNavigate } from 'react-router'
9+
import { useLocation } from 'react-router'
1010

1111
import {
1212
AccessToken16Icon,
@@ -27,7 +27,6 @@ import { ContentPane, PageContainer } from './helpers'
2727
export const handle = makeCrumb('Settings', pb.profile())
2828

2929
export default function SettingsLayout() {
30-
const navigate = useNavigate()
3130
const { pathname } = useLocation()
3231

3332
useQuickActions(
@@ -43,9 +42,9 @@ export default function SettingsLayout() {
4342
.map((i) => ({
4443
navGroup: `Settings`,
4544
value: i.value,
46-
onSelect: () => navigate(i.path),
45+
action: i.path,
4746
})),
48-
[pathname, navigate]
47+
[pathname]
4948
)
5049
)
5150

app/layouts/SiloLayout.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useMemo } from 'react'
9-
import { useLocation, useNavigate } from 'react-router'
9+
import { useLocation } from 'react-router'
1010

1111
import {
1212
Access16Icon,
@@ -25,7 +25,6 @@ import { pb } from '~/util/path-builder'
2525
import { ContentPane, PageContainer } from './helpers'
2626

2727
export default function SiloLayout() {
28-
const navigate = useNavigate()
2928
const { pathname } = useLocation()
3029
const { me } = useCurrentUser()
3130

@@ -43,9 +42,9 @@ export default function SiloLayout() {
4342
.map((i) => ({
4443
navGroup: `Silo '${me.siloName}'`,
4544
value: i.value,
46-
onSelect: () => navigate(i.path),
45+
action: i.path,
4746
})),
48-
[pathname, navigate, me.siloName]
47+
[pathname, me.siloName]
4948
)
5049
)
5150

app/layouts/SystemLayout.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useMemo } from 'react'
9-
import { useLocation, useNavigate } from 'react-router'
9+
import { useLocation } from 'react-router'
1010

1111
import { api, q, queryClient } from '@oxide/api'
1212
import {
@@ -22,7 +22,7 @@ import { trigger404 } from '~/components/ErrorBoundary'
2222
import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar'
2323
import { TopBar } from '~/components/TopBar'
2424
import { useCurrentUser } from '~/hooks/use-current-user'
25-
import { useQuickActions } from '~/hooks/use-quick-actions'
25+
import { useQuickActions, type QuickActionItem } from '~/hooks/use-quick-actions'
2626
import { Divider } from '~/ui/lib/Divider'
2727
import { inventoryBase, pb } from '~/util/path-builder'
2828

@@ -44,7 +44,6 @@ export default function SystemLayout() {
4444
// robust way of doing this would be to make a separate layout for the
4545
// silo-specific routes in the route config, but it's overkill considering
4646
// this is a one-liner. Switch to that approach at the first sign of trouble.
47-
const navigate = useNavigate()
4847
const { pathname } = useLocation()
4948

5049
const { me } = useCurrentUser()
@@ -63,16 +62,16 @@ export default function SystemLayout() {
6362
.map((i) => ({
6463
navGroup: 'System',
6564
value: i.value,
66-
onSelect: () => navigate(i.path),
65+
action: i.path,
6766
}))
6867

69-
const backToSilo = {
68+
const backToSilo: QuickActionItem = {
7069
navGroup: `Back to silo '${me.siloName}'`,
7170
value: 'Projects',
72-
onSelect: () => navigate(pb.projects()),
71+
action: pb.projects(),
7372
}
7473
return [...systemLinks, backToSilo]
75-
}, [pathname, navigate, me.siloName])
74+
}, [pathname, me.siloName])
7675

7776
useQuickActions(actions)
7877

app/pages/ProjectsPage.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,16 @@ export default function ProjectsPage() {
108108
() => [
109109
{
110110
value: 'New project',
111-
onSelect: () => navigate(pb.projectsNew()),
111+
navGroup: 'Actions',
112+
action: pb.projectsNew(),
112113
},
113114
...(allProjects?.items || []).map((p) => ({
114115
value: p.name,
115-
onSelect: () => navigate(pb.project({ project: p.name })),
116+
action: pb.project({ project: p.name }),
116117
navGroup: 'Go to project',
117118
})),
118119
],
119-
[navigate, allProjects]
120+
[allProjects]
120121
)
121122
)
122123

app/pages/SiloAccessPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ export default function SiloAccessPage() {
170170
() => [
171171
{
172172
value: 'Add user or group',
173-
onSelect: () => setAddModalOpen(true),
173+
navGroup: 'Actions',
174+
action: () => setAddModalOpen(true),
174175
},
175176
],
176177
[]

app/pages/SiloImagesPage.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'
99
import { createColumnHelper } from '@tanstack/react-table'
1010
import { useCallback, useMemo, useState } from 'react'
1111
import { useForm } from 'react-hook-form'
12-
import { Outlet, useNavigate } from 'react-router'
12+
import { Outlet } from 'react-router'
1313

1414
import { api, getListQFn, q, queryClient, useApiMutation, type Image } from '@oxide/api'
1515
import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react'
@@ -96,22 +96,22 @@ export default function SiloImagesPage() {
9696
const columns = useColsWithActions(staticCols, makeActions)
9797
const { table } = useQueryTable({ query: imageList, columns, emptyState: <EmptyState /> })
9898

99-
const navigate = useNavigate()
10099
const { data: allImages } = useQuery(q(api.imageList, { query: { limit: ALL_ISH } }))
101100
useQuickActions(
102101
useMemo(
103102
() => [
104103
{
105104
value: 'Promote image',
106-
onSelect: () => setShowModal(true),
105+
navGroup: 'Actions',
106+
action: () => setShowModal(true),
107107
},
108108
...(allImages?.items || []).map((i) => ({
109109
value: i.name,
110-
onSelect: () => navigate(pb.siloImageEdit({ image: i.name })),
110+
action: pb.siloImageEdit({ image: i.name }),
111111
navGroup: 'Go to silo image',
112112
})),
113113
],
114-
[navigate, allImages]
114+
[allImages]
115115
)
116116
)
117117

0 commit comments

Comments
 (0)