diff --git a/app/components/CopyIdItem.tsx b/app/components/CopyIdItem.tsx new file mode 100644 index 0000000000..44d462111f --- /dev/null +++ b/app/components/CopyIdItem.tsx @@ -0,0 +1,18 @@ +/* + * 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 * as DropdownMenu from '~/ui/lib/DropdownMenu' + +export function CopyIdItem({ id, label = 'Copy ID' }: { id: string; label?: string }) { + return ( + window.navigator.clipboard.writeText(id)} + label={label} + /> + ) +} diff --git a/app/components/MoreActionsMenu.tsx b/app/components/MoreActionsMenu.tsx index 774ad7c257..f176cb534a 100644 --- a/app/components/MoreActionsMenu.tsx +++ b/app/components/MoreActionsMenu.tsx @@ -6,24 +6,24 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' +import { type ReactNode } from 'react' import { More12Icon } from '@oxide/design-system/icons/react' -import type { MenuAction } from '~/table/columns/action-col' import * as DropdownMenu from '~/ui/lib/DropdownMenu' -import { Tooltip } from '~/ui/lib/Tooltip' -import { Wrap } from '~/ui/util/wrap' interface MoreActionsMenuProps { /** The accessible name for the menu button */ label: string - actions: MenuAction[] isSmall?: boolean + /** Dropdown items only */ + children?: ReactNode } + export const MoreActionsMenu = ({ - actions, label, isSmall = false, + children, }: MoreActionsMenuProps) => { return ( @@ -36,19 +36,7 @@ export const MoreActionsMenu = ({ > - - {actions.map((a) => ( - }> - - {a.label} - - - ))} - + {children} ) } diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index ac7a449a44..e2660fcb68 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -146,7 +146,7 @@ function UserMenu() { Settings - logout.mutate({})}>Sign out + logout.mutate({})} label="Sign out" /> ) diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index feeac17401..dd072d2cbd 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -21,6 +21,7 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { getInstanceSelector } from '~/hooks/use-params' import { useMetricsContext } from '~/pages/project/instances/common' import { LearnMore } from '~/ui/lib/CardBlock' +import * as Dropdown from '~/ui/lib/DropdownMenu' import { classed } from '~/util/classed' import { links } from '~/util/links' @@ -85,23 +86,12 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric
{description}
- { - const url = links.oxqlSchemaDocs(queryObj.metricName) - window.open(url, '_blank', 'noopener,noreferrer') - }, - }, - { - label: 'View OxQL query', - onActivate: () => setModalOpen(true), - }, - ]} - isSmall - /> + + + About this metric + + setModalOpen(true)} label="View OxQL query" /> + setModalOpen(false)} diff --git a/app/pages/project/instances/InstancePage.tsx b/app/pages/project/instances/InstancePage.tsx index 22e1ded896..d1f5b4599a 100644 --- a/app/pages/project/instances/InstancePage.tsx +++ b/app/pages/project/instances/InstancePage.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { filesize } from 'filesize' -import { useId, useMemo, useState } from 'react' +import { useId, useState } from 'react' import { useForm } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -27,6 +27,7 @@ import { instanceCan, instanceTransitioning, } from '~/api/util' +import { CopyIdItem } from '~/components/CopyIdItem' import { ExternalIps } from '~/components/ExternalIps' import { NumberField } from '~/components/form/fields/NumberField' import { HL } from '~/components/HL' @@ -44,6 +45,7 @@ import { import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { Button } from '~/ui/lib/Button' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -179,19 +181,6 @@ export default function InstancePage() { { enabled: !!primaryVpcId } ) - const allMenuActions = useMemo( - () => [ - { - label: 'Copy ID', - onActivate() { - window.navigator.clipboard.writeText(instance.id || '') - }, - }, - ...makeMenuActions(instance), - ], - [instance, makeMenuActions] - ) - const memory = filesize(instance.memory, { output: 'object', base: 2 }) return ( @@ -215,7 +204,28 @@ export default function InstancePage() { ))} - + + + {makeMenuActions(instance).map((action) => + 'to' in action ? ( + + {action.label} + + ) : ( + + ) + )} + diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index dba047e957..e086c879d1 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ import { useCallback } from 'react' -import { useNavigate } from 'react-router' import { instanceCan, useApiMutation, type Instance } from '@oxide/api' @@ -14,6 +13,7 @@ import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import type { MenuAction, MenuActionItem } from '~/table/columns/action-col' import { pb } from '~/util/path-builder' import { fancifyStates } from './common' @@ -50,7 +50,8 @@ export const useMakeInstanceActions = ( const { onResizeClick } = options const makeButtonActions = useCallback( - (instance: Instance) => { + // restrict to items for now so we don't have to handle links in the calling code + (instance: Instance): MenuActionItem[] => { const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { @@ -116,9 +117,8 @@ export const useMakeInstanceActions = ( [project, startInstanceAsync, stopInstanceAsync] ) - const navigate = useNavigate() const makeMenuActions = useCallback( - (instance: Instance) => { + (instance: Instance): MenuAction[] => { const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { @@ -153,9 +153,7 @@ export const useMakeInstanceActions = ( }, { label: 'View serial console', - onActivate() { - navigate(pb.serialConsole({ project, instance: instance.name })) - }, + to: pb.serialConsole({ project, instance: instance.name }), }, { label: 'Delete', @@ -179,7 +177,7 @@ export const useMakeInstanceActions = ( // Do not put `options` in here, refer to the property. options is not ref // stable. Extra renders here cause the row actions menu to close when it // shouldn't, like during polling on instance list. - [project, deleteInstanceAsync, rebootInstanceAsync, onResizeClick, navigate] + [project, deleteInstanceAsync, rebootInstanceAsync, onResizeClick] ) return { makeButtonActions, makeMenuActions } diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index c989ec22ad..72f7e4c64c 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' @@ -23,6 +23,7 @@ import { type RouterRoute, type RouteTarget, } from '~/api' +import { CopyIdItem } from '~/components/CopyIdItem' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' @@ -97,18 +98,6 @@ export default function RouterPage() { }, }) - const actions = useMemo( - () => [ - { - label: 'Copy ID', - onActivate() { - window.navigator.clipboard.writeText(routerData.id || '') - }, - }, - ], - [routerData] - ) - const emptyState = ( } @@ -197,7 +186,9 @@ export default function RouterPage() { summary="Routers are collections of routes that direct traffic between VPCs and their subnets." links={[docLinks.routers]} /> - + + + diff --git a/app/pages/project/vpcs/VpcPage.tsx b/app/pages/project/vpcs/VpcPage.tsx index 45ff0d4e42..3853772c79 100644 --- a/app/pages/project/vpcs/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' @@ -17,6 +16,7 @@ import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { pb } from '~/util/path-builder' @@ -46,33 +46,23 @@ export default function VpcPage() { }, }) - const actions = useMemo( - () => [ - { - label: 'Edit', - onActivate() { - navigate(pb.vpcEdit(vpcSelector)) - }, - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => deleteVpc({ path: { vpc: vpcName }, query: { project } }), - label: vpcName, - }), - className: 'destructive', - }, - ], - [deleteVpc, navigate, project, vpcName, vpcSelector] - ) - return ( <> }>{vpc.name}
- + + Edit + deleteVpc({ path: { vpc: vpcName }, query: { project } }), + label: vpcName, + })} + className="destructive" + /> +
diff --git a/app/pages/project/vpcs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcSubnetsTab.tsx index 354c198648..a32588557f 100644 --- a/app/pages/project/vpcs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcSubnetsTab.tsx @@ -7,7 +7,7 @@ */ 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 { getListQFn, @@ -55,14 +55,11 @@ export default function VpcSubnetsTab() { }, }) - const navigate = useNavigate() - const makeActions = useCallback( (subnet: VpcSubnet): MenuAction[] => [ { label: 'Edit', - onActivate: () => - navigate(pb.vpcSubnetsEdit({ ...vpcSelector, subnet: subnet.name })), + to: pb.vpcSubnetsEdit({ ...vpcSelector, subnet: subnet.name }), }, // TODO: only show if you have permission to do this { @@ -73,7 +70,7 @@ export default function VpcSubnetsTab() { }), }, ], - [navigate, deleteSubnet, vpcSelector] + [deleteSubnet, vpcSelector] ) const columns = useMemo( diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index cea1eab308..0bd4f21db4 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -46,6 +46,7 @@ import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' +import * as Dropdown from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -97,28 +98,6 @@ export default function IpPoolpage() { }, }) - const actions = useMemo( - () => [ - { - label: 'Edit', - onActivate() { - navigate(pb.ipPoolEdit(poolSelector)) - }, - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => deletePool({ path: { pool: pool.name } }), - label: pool.name, - }), - disabled: - !!ranges.items.length && 'IP pool cannot be deleted while it contains IP ranges', - className: ranges.items.length ? '' : 'destructive', - }, - ], - [deletePool, navigate, poolSelector, pool.name, ranges.items] - ) - return ( <> @@ -130,7 +109,21 @@ export default function IpPoolpage() { summary="IP pools are collections of external IPs you can assign to silos. When a pool is linked to a silo, users in that silo can allocate IPs from the pool for their instances." links={[docLinks.systemIpPools]} /> - + + Edit + deletePool({ path: { pool: pool.name } }), + label: pool.name, + })} + disabled={ + !!ranges.items.length && + 'IP pool cannot be deleted while it contains IP ranges' + } + className={ranges.items.length ? '' : 'destructive'} + /> + diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index 8f8f7e93cb..6d2aab3679 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -11,20 +11,32 @@ import { useMemo } from 'react' import { More12Icon } from '@oxide/design-system/icons/react' +import { CopyIdItem } from '~/components/CopyIdItem' import * as DropdownMenu from '~/ui/lib/DropdownMenu' -import { Tooltip } from '~/ui/lib/Tooltip' -import { Wrap } from '~/ui/util/wrap' -import { kebabCase } from '~/util/str' -type MakeActions = (item: Item) => Array - -export type MenuAction = { +type MenuActionBase = { label: string - onActivate: () => void - disabled?: false | React.ReactNode className?: string } +export type MenuActionItem = MenuActionBase & { + onActivate: () => void + disabled?: React.ReactNode +} + +type MenuActionLink = MenuActionBase & { + to: string + disabled?: never +} + +/** + * `to` is a URL, item will be rendered a ``. `onActivate` is a callback. + * Only the callback one can be disabled. + */ +export type MenuAction = MenuActionItem | MenuActionLink + +type MakeActions = (item: Item) => Array + /** Convenience helper to combine regular cols with actions col and memoize */ export function useColsWithActions>( /** Should be static or memoized */ @@ -66,7 +78,6 @@ type RowActionsProps = { export const RowActions = ({ id, copyIdLabel = 'Copy ID', actions }: RowActionsProps) => { return ( - {/* TODO: This name should not suck; future us, make it so! */} {/* stopPropagation prevents clicks from toggling row select in a single select table */} {/* offset moves menu in from the right so it doesn't align with the table border */} - {id && ( - { - window.navigator.clipboard.writeText(id) - }} - > - {copyIdLabel} - - )} - {actions?.map((action) => { - // TODO: Tooltip on disabled button broke, probably due to portal - return ( - } - key={kebabCase(`action-${action.label}`)} - > - - {action.label} - - + {id && } + {actions?.map(({ className, ...action }) => + 'to' in action ? ( + // note no destructive styling or disabled + + {action.label} + + ) : ( + ) - })} + )} ) diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index d4e15ee0f9..1012b4bd8f 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -17,6 +17,11 @@ import cn from 'classnames' import { type ReactNode, type Ref } from 'react' import { Link } from 'react-router' +import { OpenLink12Icon } from '@oxide/design-system/icons/react' + +import { Wrap } from '../util/wrap' +import { Tooltip } from './Tooltip' + export const Root = Menu export const Trigger = MenuButton @@ -48,36 +53,46 @@ export function Content({ className, children, anchor = 'bottom end', gap }: Con ) } -type LinkItemProps = { className?: string; to: string; children: ReactNode } +type LinkItemProps = { + className?: string + to: string + children: string | React.ReactElement +} export function LinkItem({ className, to, children }: LinkItemProps) { + // rather lazy test for external links + const ext = /^https?:/.test(to) ? { rel: 'noreferrer', target: '_blank' } : undefined + // TODO: external link icon to show when it will open in a new tab return ( - - {children} + + {children} {ext ? : null} ) } type ItemProps = { + label: string className?: string - onSelect?: () => void - children: ReactNode - disabled?: boolean + onSelect: () => void + /* If present, ReactNode will be displayed in a tooltip */ + disabled?: React.ReactNode ref?: Ref } // need to forward ref because of tooltips on disabled menu buttons -export const Item = ({ className, onSelect, children, disabled, ref }: ItemProps) => ( - - - +export const Item = ({ className, onSelect, label, disabled, ref }: ItemProps) => ( + }> + + + + ) diff --git a/app/ui/styles/components/loading-bar.css b/app/ui/styles/components/loading-bar.css index 8e7f56690e..60eae2f4ba 100644 --- a/app/ui/styles/components/loading-bar.css +++ b/app/ui/styles/components/loading-bar.css @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ - /* counterintuitively, no-preference covers the false case */ @media (prefers-reduced-motion: no-preference) { .global-loading-bar { @@ -101,7 +100,7 @@ } 100% { - opacity: 0.0; + opacity: 0; } } } diff --git a/app/ui/styles/components/menu-button.css b/app/ui/styles/components/menu-button.css index 93b01b36b9..6fb230c0b6 100644 --- a/app/ui/styles/components/menu-button.css +++ b/app/ui/styles/components/menu-button.css @@ -11,7 +11,7 @@ @apply z-topBarDropdown min-w-36 rounded border p-0 bg-raise border-secondary; & .DropdownMenuItem { - @apply block w-full cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-default border-secondary last:border-b-0; + @apply flex items-center w-full cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-default border-secondary last:border-b-0; &.destructive { @apply text-destructive; diff --git a/tsconfig.json b/tsconfig.json index 6106d6b4fa..530fa87d62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "module": "es2020", "moduleResolution": "bundler", "noEmit": true, + "noUnusedLocals": true, "outDir": "dist", "paths": { "~/*": ["app/*"],