From 4e967560d1a090ea18b6b8127eb9763fde555987 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 22 Oct 2024 15:43:27 -0700 Subject: [PATCH 01/12] Add realtime typing restrictions on NameField (#2512) * Add realtime typing restrictions on NameField * update test * Add underscore-to-dash conversion * Add a test to verify 'no sPoNgEbOb CaSe or spaces' --- app/components/form/fields/NameField.tsx | 6 ++++++ app/components/form/fields/TextField.tsx | 2 +- app/forms/network-interface-create.tsx | 14 +++++--------- test/e2e/project-create.e2e.ts | 8 +++++--- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/components/form/fields/NameField.tsx b/app/components/form/fields/NameField.tsx index a8fa6d1e16..b369f4db98 100644 --- a/app/components/form/fields/NameField.tsx +++ b/app/components/form/fields/NameField.tsx @@ -30,6 +30,12 @@ export function NameField< required={required} label={label} name={name} + transform={(value) => + value + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + } {...textFieldProps} /> ) diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx index 9163a02e48..46b2f41c22 100644 --- a/app/components/form/fields/TextField.tsx +++ b/app/components/form/fields/TextField.tsx @@ -47,7 +47,7 @@ export interface TextFieldProps< validate?: Validate, TFieldValues> control: Control /** Alters the value of the input during the field's onChange event. */ - transform?: (value: string) => FieldPathValue + transform?: (value: string) => string } export function TextField< diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 43a93b9414..e224016a8b 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -7,6 +7,7 @@ */ import { useMemo } from 'react' import { useForm } from 'react-hook-form' +import type { SetRequired } from 'type-fest' import { useApiQuery, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' @@ -19,10 +20,10 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' -const defaultValues: InstanceNetworkInterfaceCreate = { +const defaultValues: SetRequired = { name: '', description: '', - ip: undefined, + ip: '', subnetName: '', vpcName: '', } @@ -58,7 +59,7 @@ export function CreateNetworkInterfaceForm({ resourceName="network interface" title="Add network interface" onDismiss={onDismiss} - onSubmit={onSubmit} + onSubmit={({ ip, ...rest }) => onSubmit({ ip: ip.trim() || undefined, ...rest })} loading={loading} submitError={submitError} > @@ -81,12 +82,7 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - (ip.trim() === '' ? undefined : ip)} - /> + ) } diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index 6db69b6682..944c57a607 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -30,13 +30,15 @@ test.describe('Project create', () => { }) test('shows field-level validation error and does not POST', async ({ page }) => { - await page.fill('role=textbox[name="Name"]', 'Invalid name') - + const input = page.getByRole('textbox', { name: 'Name' }) + await input.pressSequentially('no sPoNgEbOb_CaSe or spaces') + await expect(input).toHaveValue('no-spongebob-case-or-spaces') + await input.fill('no-ending-dash-') // submit to trigger validation await page.getByRole('button', { name: 'Create project' }).click() await expect( - page.getByText('Can only contain lower-case letters, numbers, and dashes').nth(0) + page.getByText('Must end with a letter or number', { exact: true }).nth(0) ).toBeVisible() }) From af6a89e7363d3fd8641d01251dd188f52f835bf9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 28 Oct 2024 14:25:39 -0700 Subject: [PATCH 02/12] Add Group Attribute Name field to IdP view (#2520) * Add Group Attribute Name field to IdP view * Use text-balance to even out ragged field descriptions * add 'required' so we hide the 'optional' text * Update help copy; undo text-balance * add group attribute name to the mock IdP and test it --------- Co-authored-by: David Crespo --- app/forms/idp/create.tsx | 2 +- app/forms/idp/edit.tsx | 16 ++++++++-------- mock-api/silo.ts | 1 + test/e2e/silos.e2e.ts | 4 ++++ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 4fa8e10fa7..079d9a0ed5 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -133,7 +133,7 @@ export function CreateIdpSideModalForm() { {/* TODO: Email field, probably */} diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index a5cbc1e07a..b51d1d9043 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -104,14 +104,14 @@ export function EditIdpSideModalForm() { control={form.control} disabled /> - {/* TODO: add group attribute name when it is added to the API - */} + {/* TODO: Email field, probably */} = { slo_url: '', sp_client_id: '', technical_contact_email: '', + group_attribute_name: 'groups', } // This works differently from Nexus, but the result is the same. In Nexus, diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index b37b2e862a..3be8297cf1 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -186,6 +186,10 @@ test('Identity providers', async ({ page }) => { 'text="Single Logout (SLO) URL"', ]) + await expect(page.getByRole('textbox', { name: 'Group attribute name' })).toHaveValue( + 'groups' + ) + await page.getByRole('button', { name: 'Cancel' }).click() await expectNotVisible(page, ['role=dialog[name="Identity provider"]']) }) From 3474c6ce25e229d820e33a65e872d76333bb9150 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 28 Oct 2024 15:28:11 -0700 Subject: [PATCH 03/12] Update z index for action menu dropdowns (#2522) Co-authored-by: David Crespo --- app/components/TopBar.tsx | 2 +- app/components/TopBarPicker.tsx | 2 +- app/ui/styles/components/menu-button.css | 3 ++- tailwind.config.js | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index 8c33db531e..a9024161ca 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -55,7 +55,7 @@ export function TopBar({ children }: { children: React.ReactNode }) { - + Settings logout.mutate({})}> Sign out diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 17da2c90a5..5ae3432948 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -112,7 +112,7 @@ const TopBarPicker = (props: TopBarPickerProps) => { {/* TODO: popover position should be further right */} {props.items && ( {props.items.length > 0 ? ( diff --git a/app/ui/styles/components/menu-button.css b/app/ui/styles/components/menu-button.css index 2a88a87233..f80a7be044 100644 --- a/app/ui/styles/components/menu-button.css +++ b/app/ui/styles/components/menu-button.css @@ -7,7 +7,8 @@ */ .DropdownMenuContent { - @apply z-popover min-w-36 rounded border p-0 bg-raise border-secondary; + /* we want menu popover to be on top of top bar and pagination bar too */ + @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-secondary border-secondary last:border-b-0; diff --git a/tailwind.config.js b/tailwind.config.js index be83d8b78e..9868c46e9e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -48,7 +48,7 @@ module.exports = { modal: '40', sideModalDropdown: '40', sideModal: '30', - topBarPopover: '25', + topBarDropdown: '25', topBar: '20', popover: '10', contentDropdown: '10', From df0dea4afc4eaa02296d4225a40bd3f6d1a7706b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 29 Oct 2024 11:03:04 -0700 Subject: [PATCH 04/12] Add descriptions to IP Pool dropdowns (#2514) Co-authored-by: David Crespo --- app/components/AttachEphemeralIpModal.tsx | 15 ++-------- .../form/fields/ImageSelectField.tsx | 4 +-- app/components/form/fields/ip-pool-item.tsx | 30 +++++++++++++++++++ app/forms/floating-ip-create.tsx | 24 ++------------- app/forms/instance-create.tsx | 17 +++-------- app/forms/ip-pool-create.tsx | 9 ++++++ app/forms/ip-pool-edit.tsx | 3 ++ app/ui/styles/components/menu-list.css | 3 ++ test/e2e/floating-ip-create.e2e.ts | 8 ++--- test/e2e/instance-create.e2e.ts | 20 +++++-------- 10 files changed, 68 insertions(+), 65 deletions(-) create mode 100644 app/components/form/fields/ip-pool-item.tsx diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 878021a11f..f50c83589d 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -13,10 +13,11 @@ import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' +import { toIpPoolItem } from './form/fields/ip-pool-item' + export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const queryClient = useApiQueryClient() const { project, instance } = useInstanceSelector() @@ -54,17 +55,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) ? 'Select a pool' : 'No pools available' } - items={ - siloPools?.items.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.items.map(toIpPoolItem)} required /> diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index 2254c0cd00..0f2307c063 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -80,10 +80,10 @@ export function toImageComboboxItem( value: id, selectedLabel: name, label: ( - <> +
{name}
{itemMetadata}
- +
), } } diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx new file mode 100644 index 0000000000..eafed84c71 --- /dev/null +++ b/app/components/form/fields/ip-pool-item.tsx @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { SiloIpPool } from '~/api' +import { Badge } from '~/ui/lib/Badge' + +export function toIpPoolItem(p: SiloIpPool) { + const value = p.name + const selectedLabel = p.name + const label = ( +
+
+ {p.name} + {p.isDefault && ( + + default + + )} +
+ {p.description.length && ( +
{p.description}
+ )} +
+ ) + return { value, selectedLabel, label } +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 77424696c7..65a9d742bf 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -15,40 +15,20 @@ import { useApiQuery, useApiQueryClient, type FloatingIpCreate, - type SiloIpPool, } from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const toListboxItem = (p: SiloIpPool) => { - if (!p.isDefault) { - return { value: p.name, label: p.name } - } - // For the default pool, add a label to the dropdown - return { - value: p.name, - selectedLabel: p.name, - label: ( - <> - {p.name}{' '} - - default - - - ), - } -} - const defaultValues: Omit = { name: '', description: '', @@ -108,7 +88,7 @@ export function CreateFloatingIpSideModalForm() { toListboxItem(p))} + items={(allPools?.items || []).map(toIpPoolItem)} label="IP pool" control={form.control} placeholder="Select a pool" diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 35f91958c5..be7e9eaa2d 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -25,6 +25,7 @@ import { type InstanceCreate, type InstanceDiskAttachment, type NameOrId, + type SiloIpPool, } from '@oxide/api' import { Images16Icon, @@ -46,6 +47,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -57,7 +59,6 @@ import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { Checkbox } from '~/ui/lib/Checkbox' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -609,7 +610,7 @@ const AdvancedAccordion = ({ }: { control: Control isSubmitting: boolean - siloPools: Array<{ name: string; isDefault: boolean }> + siloPools: Array }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -733,17 +734,7 @@ const AdvancedAccordion = ({ label="IP pool for ephemeral IP" placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'} selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`} - items={ - siloPools.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.map(toIpPoolItem)} disabled={!assignEphemeralIp || isSubmitting} required onChange={(value) => { diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index c91e8d8d31..e1f87b15d9 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' const defaultValues: IpPoolCreate = { @@ -51,6 +52,14 @@ export function CreateIpPoolSideModalForm() { > + ) } + +export const IpPoolVisibilityMessage = () => ( + +) diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 73e2c942c5..2b1a15978e 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -22,6 +22,8 @@ import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import { IpPoolVisibilityMessage } from './ip-pool-create' + EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { pool } = getIpPoolSelector(params) await apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }) @@ -68,6 +70,7 @@ export function EditIpPoolSideModalForm() { > + ) } diff --git a/app/ui/styles/components/menu-list.css b/app/ui/styles/components/menu-list.css index 4241dec636..3aa0d0ff10 100644 --- a/app/ui/styles/components/menu-list.css +++ b/app/ui/styles/components/menu-list.css @@ -28,6 +28,9 @@ .ox-menu-item.is-selected { @apply border-0 text-accent bg-accent-secondary hover:bg-accent-secondary-hover; + .ox-badge { + @apply ring-0 text-inverse bg-accent; + } } /* beautiful ring */ diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index dfe5af2091..4bedc596ce 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,19 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const poolListbox = page.getByRole('button', { name: 'IP pool' }) + const label = page.getByLabel('IP pool') // accordion content should be hidden - await expect(poolListbox).toBeHidden() + await expect(label).toBeHidden() // open accordion await page.getByRole('button', { name: 'Advanced' }).click() // accordion content should be visible - await expect(poolListbox).toBeVisible() + await expect(label).toBeVisible() // choose pool and submit - await poolListbox.click() + await label.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 628b92b9b5..e2e2125100 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -70,27 +70,23 @@ test('can create an instance', async ({ page }) => { await page.getByRole('button', { name: 'Networking' }).click() await page.getByRole('button', { name: 'Configuration' }).click() - const assignEphemeralIpCheckbox = page.getByRole('checkbox', { + const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const assignEphemeralIpButton = page.getByRole('button', { - name: 'IP pool for ephemeral IP', - }) + const label = page.getByLabel('IP pool for ephemeral IP') // verify that the ip pool selector is visible and default is selected - await expect(assignEphemeralIpCheckbox).toBeChecked() - await assignEphemeralIpButton.click() + await expect(checkbox).toBeChecked() + await label.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - await assignEphemeralIpButton.click() // click closes the listbox so we can do more stuff // unchecking the box should disable the selector - await assignEphemeralIpCheckbox.uncheck() - await expect(assignEphemeralIpButton).toBeHidden() + await checkbox.uncheck() + await expect(label).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable - await assignEphemeralIpCheckbox.check() - await assignEphemeralIpButton.click() - await page.getByRole('option', { name: 'ip-pool-2' }).click() + await checkbox.check() + await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() From eb7f8567cf9497673aebb59e1406dbc8622122cc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 29 Oct 2024 19:35:20 -0700 Subject: [PATCH 05/12] Make toast copy more consistent (#2504) * Make toast copy more consistent * Add confirmation for disk deletion * Add highlighting to toast contents * keep str test * Fix tests; a few more ToastContents, but will hold here * Move away from ToastContent * Migrate other addToasts to new approach * Add toast on a couple of onSuccess functions missing them * Fix a few tests * Missed a couple * Formatting * surely saying this was the last test to fix won't jinx it * nope; it was not fully reading the previous CI report * Add expectToast helper * use a lighter weight font * Close toasts at end of expectToast * let addToast take the content directly, fix type errors, change one case * a billion one-liner toasts with the magic of prettier-ignore * eliminate HLs by using CSS instead * actually..... do it in tailwind with group. yowza * undo spurious changes to checksums in package-lock.json --------- Co-authored-by: David Crespo --- .github/workflows/lintBuildTest.yml | 2 +- app/components/AttachEphemeralIpModal.tsx | 5 +-- app/components/AttachFloatingIpModal.tsx | 5 +-- app/components/HL.tsx | 9 ++++- app/components/ToastStack.tsx | 5 ++- app/forms/disk-create.tsx | 3 +- app/forms/firewall-rules-create.tsx | 6 ++-- app/forms/firewall-rules-edit.tsx | 6 +++- app/forms/floating-ip-create.tsx | 5 +-- app/forms/floating-ip-edit.tsx | 3 +- app/forms/idp/create.tsx | 5 +-- app/forms/image-from-snapshot.tsx | 5 +-- app/forms/instance-create.tsx | 2 +- app/forms/ip-pool-create.tsx | 3 +- app/forms/ip-pool-edit.tsx | 3 +- app/forms/network-interface-edit.tsx | 5 ++- app/forms/project-access.tsx | 4 +++ app/forms/project-create.tsx | 3 +- app/forms/project-edit.tsx | 3 +- app/forms/silo-create.tsx | 3 +- app/forms/snapshot-create.tsx | 5 +-- app/forms/ssh-key-create.tsx | 5 +-- app/forms/subnet-create.tsx | 5 ++- app/forms/subnet-edit.tsx | 5 ++- app/forms/vpc-create.tsx | 3 +- app/forms/vpc-edit.tsx | 3 +- app/forms/vpc-router-create.tsx | 5 +-- app/forms/vpc-router-edit.tsx | 5 +-- app/forms/vpc-router-route-create.tsx | 5 +-- app/forms/vpc-router-route-edit.tsx | 5 +-- app/pages/LoginPage.tsx | 2 +- .../project/access/ProjectAccessPage.tsx | 6 +++- app/pages/project/disks/DisksPage.tsx | 10 +++--- .../project/floating-ips/FloatingIpsPage.tsx | 12 +++---- app/pages/project/images/ImagesPage.tsx | 9 +++-- app/pages/project/instances/actions.tsx | 9 ++--- .../instances/instance/tabs/NetworkingTab.tsx | 10 +++--- .../instances/instance/tabs/StorageTab.tsx | 8 ++--- app/pages/project/vpcs/RouterPage.tsx | 3 +- app/pages/project/vpcs/VpcPage/VpcPage.tsx | 5 +-- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 5 +-- .../vpcs/VpcPage/tabs/VpcSubnetsTab.tsx | 3 ++ app/pages/project/vpcs/VpcsPage.tsx | 5 +-- app/pages/settings/SSHKeysPage.tsx | 5 +-- app/pages/system/SiloImagesPage.tsx | 11 ++++-- app/pages/system/networking/IpPoolPage.tsx | 4 +-- app/pages/system/networking/IpPoolsPage.tsx | 5 +-- app/pages/system/silos/SiloIpPoolsTab.tsx | 2 ++ app/pages/system/silos/SiloQuotasTab.tsx | 2 ++ app/pages/system/silos/SilosPage.tsx | 5 ++- app/stores/toast.ts | 12 ++++++- app/ui/lib/Toast.tsx | 17 ++++++--- app/util/{str.spec.ts => str.spec.tsx} | 36 ++++++++++++++++++- app/util/str.ts | 18 ++++++++++ test/e2e/disks.e2e.ts | 31 +++++++++++----- test/e2e/floating-ip-update.e2e.ts | 11 +++++- test/e2e/images.e2e.ts | 17 ++++----- test/e2e/instance-disks.e2e.ts | 12 ++++--- test/e2e/ip-pools.e2e.ts | 10 +++--- test/e2e/utils.ts | 15 ++++++++ 60 files changed, 311 insertions(+), 120 deletions(-) rename app/util/{str.spec.ts => str.spec.tsx} (78%) diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 55af5bc78c..b7cedaa85f 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Cache node_modules uses: actions/cache@v4 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index f50c83589d..25177c0dde 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' @@ -29,9 +30,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) [siloPools] ) const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', { - onSuccess() { + onSuccess(ephemeralIp) { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been attached' }) + addToast(<>IP {ephemeralIp.ip} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index eaedd2dbdc..cc351bde18 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, type FloatingIp, type Instance } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -45,10 +46,10 @@ export const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/HL.tsx b/app/components/HL.tsx index 234dab7d30..9a5ca1e1b1 100644 --- a/app/components/HL.tsx +++ b/app/components/HL.tsx @@ -7,4 +7,11 @@ */ import { classed } from '~/util/classed' -export const HL = classed.span`text-sans-semi-md text-default` +// note parent with secondary text color must have 'group' on it for +// this to work. see Toast for an example +export const HL = classed.span` + text-sans-md text-default + group-[.text-accent-secondary]:text-accent + group-[.text-error-secondary]:text-error + group-[.text-info-secondary]:text-info +` diff --git a/app/components/ToastStack.tsx b/app/components/ToastStack.tsx index 56d5c9a4b5..78fe655e69 100644 --- a/app/components/ToastStack.tsx +++ b/app/components/ToastStack.tsx @@ -22,7 +22,10 @@ export function ToastStack() { }) return ( -
+
{transition((style, item) => ( Disk {data.name} created) // prettier-ignore onSuccess?.(data) onDismiss(navigate) }, diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 99bebe081f..35aee97230 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -18,6 +18,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' @@ -74,9 +75,10 @@ export function CreateFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules) { + const newRule = updatedRules.rules[updatedRules.rules.length - 1] queryClient.invalidateQueries('vpcFirewallRulesView') - addToast({ content: 'Your firewall rule has been created' }) + addToast(<>Firewall rule {newRule.name} created) // prettier-ignore navigate(pb.vpcFirewallRules(vpcSelector)) }, }) diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 50957bff10..bbea4f975e 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -18,11 +18,13 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFirewallRuleSelector, useFirewallRuleSelector, useVpcSelector, } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' @@ -64,13 +66,15 @@ export function EditFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules, { body }) { // Nav before the invalidate because I once saw the above invariant fail // briefly after successful edit (error page flashed but then we land // on the rules list ok) and I think it was a race condition where the // invalidate managed to complete while the modal was still open. onDismiss() queryClient.invalidateQueries('vpcFirewallRulesView') + const updatedRule = body.rules[body.rules.length - 1] + addToast(<>Firewall rule {updatedRule.name} updated) // prettier-ignore }, }) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 65a9d742bf..cab5b694e6 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -23,6 +23,7 @@ import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -45,10 +46,10 @@ export function CreateFloatingIpSideModalForm() { const navigate = useNavigate() const createFloatingIp = useApiMutation('floatingIpCreate', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your Floating IP has been created' }) + addToast(<>Floating IP {floatingIp.name} created) // prettier-ignore navigate(pb.floatingIps(projectSelector)) }, }) diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 44b19bd538..26fe356f92 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from 'app/util/path-builder' @@ -47,7 +48,7 @@ export function EditFloatingIpSideModalForm() { const editFloatingIp = useApiMutation('floatingIpUpdate', { onSuccess(_floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been updated' }) + addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 079d9a0ed5..42004e88e1 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -15,6 +15,7 @@ import { FileField } from '~/components/form/fields/FileField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { readBlobAsBase64 } from '~/util/file' @@ -51,9 +52,9 @@ export function CreateIdpSideModalForm() { const onDismiss = () => navigate(pb.silo({ silo })) const createIdp = useApiMutation('samlIdentityProviderCreate', { - onSuccess() { + onSuccess(idp) { queryClient.invalidateQueries('siloIdentityProviderList') - addToast({ content: 'Your identity provider has been created' }) + addToast(<>IdP {idp.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 48c6b9e793..c6a9ac1e20 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -21,6 +21,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSnapshotSelector, useProjectSnapshotSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -54,9 +55,9 @@ export function CreateImageFromSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots({ project })) const createImage = useApiMutation('imageCreate', { - onSuccess() { + onSuccess(image) { queryClient.invalidateQueries('imageList') - addToast({ content: 'Your image has been created' }) + addToast(<>Image {image.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index be7e9eaa2d..8350ccf20a 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -184,7 +184,7 @@ export function CreateInstanceForm() { { path: { instance: instance.name }, query: { project } }, instance ) - addToast({ content: 'Your instance has been created' }) + addToast(<>Instance {instance.name} created) // prettier-ignore navigate(pb.instance({ project, instance: instance.name })) }, }) diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index e1f87b15d9..8afa803e9e 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type IpPoolCreate } from '@oxide/api import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' @@ -31,7 +32,7 @@ export function CreateIpPoolSideModalForm() { const createPool = useApiMutation('ipPoolCreate', { onSuccess(_pool) { queryClient.invalidateQueries('ipPoolList') - addToast({ content: 'Your IP pool has been created' }) + addToast(<>IP pool {_pool.name} created) // prettier-ignore navigate(pb.ipPools()) }, }) diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 2b1a15978e..cbd0b7db7d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -43,7 +44,7 @@ export function EditIpPoolSideModalForm() { onSuccess(updatedPool) { queryClient.invalidateQueries('ipPoolList') navigate(pb.ipPool({ pool: updatedPool.name })) - addToast({ content: 'Your IP pool has been updated' }) + addToast(<>IP pool {updatedPool.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating ipPoolView causes an error page to flash diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index c57bde7899..401403f900 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -20,7 +20,9 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextFieldInner } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import * as MiniTable from '~/ui/lib/MiniTable' @@ -42,8 +44,9 @@ export function EditNetworkInterfaceForm({ const instanceSelector = useInstanceSelector() const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', { - onSuccess() { + onSuccess(nic) { queryClient.invalidateQueries('instanceNetworkInterfaceList') + addToast(<>Network interface {nic.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 826b587744..ae9551cd37 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -17,6 +17,7 @@ import { import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { actorToItem, @@ -35,6 +36,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + // We don't have the name of the user or group, so we'll just have a generic message + addToast({ content: 'Role assigned' }) onDismiss() }, }) @@ -97,6 +100,7 @@ export function ProjectAccessEditUserSideModal({ const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Role updated' }) onDismiss() }, }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 020894826c..faaee13df7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type ProjectCreate } from '@oxide/ap import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -33,7 +34,7 @@ export function CreateProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been created' }) + addToast(<>Project {project.name} created) // prettier-ignore navigate(pb.project({ project: project.name })) }, }) diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 284c1de8de..7af23a1723 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -45,7 +46,7 @@ export function EditProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been updated' }) + addToast(<>Project {project.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 9508386bae..ea6b82651e 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -19,6 +19,7 @@ import { RadioField } from '~/components/form/fields/RadioField' import { TextField } from '~/components/form/fields/TextField' import { TlsCertsField } from '~/components/form/fields/TlsCertsField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -57,7 +58,7 @@ export function CreateSiloSideModalForm() { onSuccess(silo) { queryClient.invalidateQueries('siloList') queryClient.setQueryData('siloView', { path: { silo: silo.name } }, silo) - addToast({ content: 'Your silo has been created' }) + addToast(<>Silo {silo.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 930cec2381..25c7f90db8 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -22,6 +22,7 @@ import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -52,9 +53,9 @@ export function CreateSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots(projectSelector)) const createSnapshot = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Your snapshot has been created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 14e5b399a3..82ba183e23 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -35,10 +36,10 @@ export function CreateSSHKeySideModalForm({ onDismiss, message }: Props) { const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys()) const createSshKey = useApiMutation('currentUserSshKeyCreate', { - onSuccess() { + onSuccess(sshKey) { queryClient.invalidateQueries('currentUserSshKeyList') handleDismiss() - addToast({ content: 'Your SSH key has been created' }) + addToast(<>SSH key {sshKey.name} created) // prettier-ignore }, }) const form = useForm({ defaultValues }) diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 5ed229999c..e2bbb2666a 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -20,7 +20,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -42,9 +44,10 @@ export function CreateSubnetForm() { const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector)) const createSubnet = useApiMutation('vpcSubnetCreate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') onDismiss() + addToast(<>Subnet {subnet.name} created) // prettier-ignore }, }) diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 6bfd7e18c1..49ab973fbc 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -25,7 +25,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -51,8 +53,9 @@ export function EditSubnetForm() { }) const updateSubnet = useApiMutation('vpcSubnetUpdate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') + addToast(<>Subnet {subnet.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index f93d040b8c..43f8fa15a1 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -38,7 +39,7 @@ export function CreateVpcSideModalForm() { { path: { vpc: vpc.name }, query: projectSelector }, vpc ) - addToast({ content: 'Your VPC has been created' }) + addToast(<>VPC {vpc.name} created) // prettier-ignore navigate(pb.vpc({ vpc: vpc.name, ...projectSelector })) }, }) diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 9a6380f5fa..0982d17f10 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -42,7 +43,7 @@ export function EditVpcSideModalForm() { onSuccess(updatedVpc) { queryClient.invalidateQueries('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) - addToast({ content: 'Your VPC has been updated' }) + addToast(<>VPC {updatedVpc.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating vpcView causes an error page to flash diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index c808d3a099..3d08d456cc 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -30,9 +31,9 @@ export function CreateRouterSideModalForm() { const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) const createRouter = useApiMutation('vpcRouterCreate', { - onSuccess() { + onSuccess(router) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been created' }) + addToast(<>Router {router.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 3d8067022d..134aadcf26 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -23,6 +23,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -51,9 +52,9 @@ export function EditRouterSideModalForm() { } const editRouter = useApiMutation('vpcRouterUpdate', { - onSuccess() { + onSuccess(updatedRouter) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been updated' }) + addToast(<>Router {updatedRouter.name} updated) // prettier-ignore navigate(pb.vpcRouters({ project, vpc })) }, }) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 4ed2afe6c0..8030b55dcd 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -11,6 +11,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -44,9 +45,9 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { - onSuccess() { + onSuccess(route) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been created' }) + addToast(<>Route {route.name} created) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 19ac9934e2..da1c06338e 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -17,6 +17,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, routeFormMessage, @@ -62,9 +63,9 @@ export function EditRouterRouteSideModalForm() { const disabled = route?.kind === 'vpc_subnet' const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { - onSuccess() { + onSuccess(updatedRoute) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been updated' }) + addToast(<>Route {updatedRoute.name} updated) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index 92b14cc7f2..4ff7a48d2d 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -35,7 +35,7 @@ export function LoginPage() { useEffect(() => { if (loginPost.isSuccess) { - addToast({ title: 'Logged in' }) + addToast('Logged in') navigate(searchParams.get('redirect_uri') || pb.projects()) } }, [loginPost.isSuccess, navigate, searchParams]) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 832749910e..05173294af 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -34,6 +34,7 @@ import { } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' @@ -119,7 +120,10 @@ export function ProjectAccessPage() { const queryClient = useApiQueryClient() const { mutateAsync: updatePolicy } = useApiMutation('projectPolicyUpdate', { - onSuccess: () => queryClient.invalidateQueries('projectPolicyView'), + onSuccess: () => { + queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 11e0c215d6..298e4af5f9 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -20,6 +20,7 @@ import { import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { DiskStateBadge } from '~/components/StateBadge' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -99,15 +100,16 @@ export function DisksPage() { const { Table } = useQueryTable('diskList', { query: { project } }) const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('diskList') + addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot successfully created' }) + addToast(<>Snapshot {variables.body.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -123,7 +125,7 @@ export function DisksPage() { { label: 'Snapshot', onActivate() { - addToast({ title: `Creating snapshot of disk '${disk.name}'` }) + addToast(<>Creating snapshot of disk {disk.name}) // prettier-ignore createSnapshot({ query: { project }, body: { diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 08d035c033..ae5b95b57c 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -108,19 +108,19 @@ export function FloatingIpsPage() { const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your floating IP has been deleted' }) + addToast(<>Floating IP {variables.path.floatingIp} deleted) // prettier-ignore }, }) @@ -250,9 +250,9 @@ const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 726357a6f8..cf5c43dee2 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type Image } from '@ import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -58,7 +59,7 @@ export function ImagesPage() { const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,11 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ - content: `${data.name} has been promoted`, + content: ( + <> + Image {data.name} promoted + + ), cta: { text: 'View silo images', link: '/images', diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index df251f3ab5..b18886dd9a 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -58,7 +58,7 @@ export const useMakeInstanceActions = ( label: 'Start', onActivate() { startInstance(instanceParams, { - onSuccess: () => addToast({ title: `Starting instance '${instance.name}'` }), + onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -79,7 +79,7 @@ export const useMakeInstanceActions = ( doAction: () => stopInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Stopping instance '${instance.name}'` }), + addToast(<>Stopping instance {instance.name}), // prettier-ignore }), modalTitle: 'Confirm stop instance', modalContent: ( @@ -104,7 +104,8 @@ export const useMakeInstanceActions = ( label: 'Reboot', onActivate() { rebootInstance(instanceParams, { - onSuccess: () => addToast({ title: `Rebooting instance '${instance.name}'` }), + onSuccess: () => + addToast(<>Rebooting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -129,7 +130,7 @@ export const useMakeInstanceActions = ( doDelete: () => deleteInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Deleting instance '${instance.name}'` }), + addToast(<>Deleting instance {instance.name}), // prettier-ignore }), label: instance.name, resourceKind: 'instance', diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 0082f7cfc4..d1d8c11431 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -202,9 +202,9 @@ export function NetworkingTab() { }, }) const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('instanceNetworkInterfaceList') - addToast({ content: 'Network interface deleted' }) + addToast(<>Network interface {variables.path.interface} deleted) // prettier-ignore }, }) const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', { @@ -297,7 +297,7 @@ export function NetworkingTab() { const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', { onSuccess() { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been detached' }) + addToast({ content: 'Ephemeral IP detached' }) }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) @@ -305,10 +305,10 @@ export function NetworkingTab() { }) const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {variables.path.floatingIp} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 1083b509e5..2b7c24d126 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -87,9 +87,9 @@ export function StorageTab() { ) const { mutate: detachDisk } = useApiMutation('instanceDiskDetach', { - onSuccess() { + onSuccess(disk) { queryClient.invalidateQueries('instanceDiskList') - addToast({ content: 'Disk detached' }) + addToast(<>Disk {disk.name} detached) // prettier-ignore }, onError(err) { addToast({ @@ -100,9 +100,9 @@ export function StorageTab() { }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore }, onError(err) { addToast({ diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index b91ae862a5..1374602b31 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -90,7 +90,8 @@ export function RouterPage() { const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been deleted' }) + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Route deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 5c5c5d912a..97adda95eb 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -16,6 +16,7 @@ import { } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' @@ -46,10 +47,10 @@ export function VpcPage() { }) const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') navigate(pb.vpcs({ project })) - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 361d7a4921..cd411c4c90 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -11,6 +11,7 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -62,9 +63,9 @@ export function VpcRoutersTab() { ) const { mutateAsync: deleteRouter } = useApiMutation('vpcRouterDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been deleted' }) + addToast(<>Router {variables.path.router} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 285bb2b82c..0dcb974a19 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -18,6 +18,7 @@ import { import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' @@ -47,6 +48,8 @@ export function VpcSubnetsTab() { const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { onSuccess() { queryClient.invalidateQueries('vpcSubnetList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Subnet deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index e5ce773f28..69df4371e3 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -20,6 +20,7 @@ import { import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -83,9 +84,9 @@ export function VpcsPage() { const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 97ad48f883..3b2fd881c8 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type SshKey } from ' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -46,9 +47,9 @@ export function SSHKeysPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries('currentUserSshKeyList') - addToast({ content: 'Your SSH key has been deleted' }) + addToast(<>SSH key {variables.path.sshKey} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 6c27a1cf53..2346153936 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -23,6 +23,7 @@ import { DocsPopover } from '~/components/DocsPopover' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -72,7 +73,7 @@ export function SiloImagesPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { - addToast({ content: `${data.name} has been promoted` }) + addToast(<>Image {data.name} promoted) // prettier-ignore queryClient.invalidateQueries('imageList') }, onError: (err) => { @@ -218,7 +219,11 @@ const DemoteImageModal = ({ const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ - content: `${data.name} has been demoted`, + content: ( + <> + Image {data.name} demoted + + ), cta: selectedProject ? { text: `View images in ${selectedProject}`, diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 7461dc41c1..a5c44487e7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -82,10 +82,10 @@ export function IpPoolPage() { }) const navigate = useNavigate() const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') navigate(pb.ipPools()) - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 8084dc77f7..eaa8fcf386 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -20,6 +20,7 @@ import { import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { IpUtilCell } from '~/components/IpPoolUtilization' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -78,9 +79,9 @@ export function IpPoolsPage() { }) const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 81aef8d4c9..b19a51ba88 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -81,6 +81,8 @@ export function SiloIpPoolsTab() { const { mutateAsync: unlinkPool } = useApiMutation('ipPoolSiloUnlink', { onSuccess() { queryClient.invalidateQueries('siloIpPoolList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'IP pool unlinked' }) }, }) diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 14df8fbb74..8037974cd1 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -18,6 +18,7 @@ import { import { NumberField } from '~/components/form/fields/NumberField' import { SideModalForm } from '~/components/form/SideModalForm' import { useSiloSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' @@ -106,6 +107,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const updateQuotas = useApiMutation('siloQuotasUpdate', { onSuccess() { apiQueryClient.invalidateQueries('siloUtilizationView') + addToast({ content: 'Quotas updated' }) onDismiss() }, }) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 6fbec47227..7f10b98449 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -19,8 +19,10 @@ import { import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -76,8 +78,9 @@ export function SilosPage() { }) const { mutateAsync: deleteSilo } = useApiMutation('siloDelete', { - onSuccess() { + onSuccess(silo, { path }) { queryClient.invalidateQueries('siloList') + addToast(<>Silo {path.silo} deleted) // prettier-ignore }, }) diff --git a/app/stores/toast.ts b/app/stores/toast.ts index ea06db7213..6bf3c4f5e8 100644 --- a/app/stores/toast.ts +++ b/app/stores/toast.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { type ReactElement } from 'react' import { v4 as uuid } from 'uuid' import { create } from 'zustand' @@ -17,9 +18,18 @@ type Toast = { export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) -export function addToast(options: Toast['options']) { +/** + * If argument is `ReactElement | string`, use it directly as `{ content }`. + * Otherwise it's a config object. + */ +export function addToast(optionsOrContent: Toast['options'] | ReactElement | string) { + const options = + typeof optionsOrContent === 'object' && 'content' in optionsOrContent + ? optionsOrContent + : { content: optionsOrContent } useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })) } + export function removeToast(id: Toast['id']) { useToastStore.setState(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })) } diff --git a/app/ui/lib/Toast.tsx b/app/ui/lib/Toast.tsx index c11a32d059..05d3cd8dbd 100644 --- a/app/ui/lib/Toast.tsx +++ b/app/ui/lib/Toast.tsx @@ -7,7 +7,7 @@ */ import { announce } from '@react-aria/live-announcer' import cn from 'classnames' -import { useEffect, type ReactElement } from 'react' +import { useEffect, type ReactElement, type ReactNode } from 'react' import { Link, type To } from 'react-router-dom' import { @@ -17,6 +17,8 @@ import { Warning12Icon, } from '@oxide/design-system/icons/react' +import { extractText } from '~/util/str' + import { TimeoutIndicator } from './TimeoutIndicator' import { Truncate } from './Truncate' @@ -24,7 +26,7 @@ type Variant = 'success' | 'error' | 'info' export interface ToastProps { title?: string - content?: string + content: ReactNode onClose: () => void variant?: Variant timeout?: number | null @@ -82,7 +84,7 @@ export const Toast = ({ const timeout = timeoutArg === undefined ? defaultTimeout : timeoutArg // TODO: consider assertive announce for error toasts useEffect( - () => announce((title || defaultTitle[variant]) + ' ' + content, 'polite'), + () => announce((title || defaultTitle[variant]) + ' ' + extractText(content), 'polite'), [title, content, variant] ) return ( @@ -95,8 +97,13 @@ export const Toast = ({ >
{icon[variant]}
-
{title || defaultTitle[variant]}
-
{content}
+ {(title || variant !== 'success') && ( +
{title || defaultTitle[variant]}
+ )} + {/* 'group' is necessary for HL color trick to work. see HL.tsx */} +
+ {content} +
{cta && ( { it('capitalizes the first letter', () => { @@ -76,3 +83,30 @@ describe('titleCase', () => { expect(titleCase('123 abc')).toBe('123 Abc') }) }) + +describe('extractText', () => { + it('extracts strings from React components', () => { + expect( + extractText( + <> + This is my text + + ) + ).toBe('This is my text') + }) + it('extracts strings from nested elements', () => { + expect( + extractText( +

+ This is my{' '} + + nested text + +

+ ) + ).toBe('This is my nested text') + }) + it('can handle regular strings', () => { + expect(extractText('Some more text')).toBe('Some more text') + }) +}) diff --git a/app/util/str.ts b/app/util/str.ts index 934530917c..a7620050c9 100644 --- a/app/util/str.ts +++ b/app/util/str.ts @@ -6,6 +6,8 @@ * Copyright Oxide Computer Company */ +import React from 'react' + export const capitalize = (s: string) => s && s.charAt(0).toUpperCase() + s.slice(1) export const pluralize = (s: string, n: number) => `${n} ${s}${n === 1 ? '' : 's'}` @@ -55,3 +57,19 @@ export const titleCase = (text: string): string => { * it look like `AAAAAAAAAAAAAAAA==`? */ export const isAllZeros = (base64Data: string) => /^A*=*$/.test(base64Data) + +/** + * Extract the string contents of a ReactNode, so <>This highlighted text becomes "This highlighted text" + */ +export const extractText = (children: React.ReactNode): string => + React.Children.toArray(children) + .map((child) => + typeof child === 'string' + ? child + : React.isValidElement(child) + ? extractText(child.props.children) + : '' + ) + .join(' ') + .trim() + .replace(/\s+/g, ' ') diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 03398c4cd6..e735f7dad6 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -5,7 +5,15 @@ * * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectNoToast, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') @@ -28,8 +36,11 @@ test('List disks and snapshot', async ({ page }) => { }) await clickRowAction(page, 'disk-1 db1', 'Snapshot') - await expect(page.getByText("Creating snapshot of disk 'disk-1'").nth(0)).toBeVisible() - await expect(page.getByText('Snapshot successfully created').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-1') + // expectToast should have closed the toast already, but verify + await expectNoToast(page, 'Creating snapshot of disk disk-1') + // Next line is a little awkward, but we don't actually know what the snapshot name will be + await expectToast(page, /Snapshot disk-1-[a-z0-9]{6} created/) }) test('Disk snapshot error', async ({ page }) => { @@ -37,11 +48,13 @@ test('Disk snapshot error', async ({ page }) => { // special disk that triggers snapshot error await clickRowAction(page, 'disk-snapshot-error', 'Snapshot') - await expect( - page.getByText("Creating snapshot of disk 'disk-snapshot-error'").nth(0) - ).toBeVisible() - await expect(page.getByText('Failed to create snapshot').nth(0)).toBeVisible() - await expect(page.getByText('Cannot snapshot disk').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-snapshot-error') + // just including an actual expect to satisfy the linter + await expect(page.getByRole('cell', { name: 'disk-snapshot-error' })).toBeVisible() + // expectToast should have closed the toast already, but let's just verify … + await expectNoToast(page, 'Creating snapshot of disk disk-snapshot-error') + // … before we can check for the error toast + await expectToast(page, 'Failed to create snapshotCannot snapshot disk') }) test.describe('Disk create', () => { @@ -53,7 +66,7 @@ test.describe('Disk create', () => { test.afterEach(async ({ page }) => { await page.getByRole('button', { name: 'Create disk' }).click() - await expectVisible(page, ['text="Your disk has been created"']) + await expectToast(page, 'Disk a-new-disk created') await expectVisible(page, ['role=cell[name="a-new-disk"]']) }) diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 68bcf0d05d..4ce1179c90 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -6,7 +6,14 @@ * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' const floatingIpsPage = '/projects/mock-project/floating-ips' const originalName = 'cola-float' @@ -32,6 +39,7 @@ test('can update a floating IP', async ({ page }) => { name: updatedName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${updatedName} updated`) }) // Make sure that it still works even if the name doesn't change @@ -47,4 +55,5 @@ test('can update *just* the floating IP description', async ({ page }) => { name: originalName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${originalName} updated`) }) diff --git a/test/e2e/images.e2e.ts b/test/e2e/images.e2e.ts index c15f91fc80..d78f83ca0a 100644 --- a/test/e2e/images.e2e.ts +++ b/test/e2e/images.e2e.ts @@ -12,6 +12,7 @@ import { clipboardText, expect, expectNotVisible, + expectToast, expectVisible, getPageAsUser, selectOption, @@ -52,7 +53,7 @@ test('can promote an image from silo', async ({ page }) => { await page.locator('role=button[name="Promote"]').click() // Check it was promoted successfully - await expectVisible(page, ['text="image-1 has been promoted"']) + await expect(page.getByText('Image image-1 promoted', { exact: true })).toBeVisible() await expectVisible(page, ['role=cell[name="image-1"]']) }) @@ -68,7 +69,7 @@ test('can promote an image from project', async ({ page }) => { // Promote image and check it was successful await page.locator('role=button[name="Promote"]').click() - await expectVisible(page, ['text="image-2 has been promoted"']) + await expect(page.getByText('Image image-2 promoted', { exact: true })).toBeVisible() await expectNotVisible(page, ['role=cell[name="image-2"]']) await page.click('role=link[name="View silo images"]') @@ -111,8 +112,10 @@ test('can demote an image from silo', async ({ page }) => { await selectOption(page, 'Project', 'mock-project') await page.getByRole('button', { name: 'Demote' }).click() - // Promote image and check it was successful - await expectVisible(page, ['text="arch-2022-06-01 has been demoted"']) + // Demote image and check it was successful + await expect( + page.getByText('Image arch-2022-06-01 demoted', { exact: true }) + ).toBeVisible() await expectNotVisible(page, ['role=cell[name="arch-2022-06-01"]']) await page.click('role=link[name="View images in mock-project"]') @@ -132,7 +135,7 @@ test('can delete an image from a project', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect(page.getByText('image-3 has been deleted', { exact: true })).toBeVisible() + await expectToast(page, 'Image image-3 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) @@ -150,9 +153,7 @@ test('can delete an image from a silo', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect( - page.getByText('ubuntu-20-04 has been deleted', { exact: true }) - ).toBeVisible() + await expectToast(page, 'Image ubuntu-20-04 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index 9e54716196..b53e872e4d 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -8,8 +8,10 @@ import { clickRowAction, expect, + expectNoToast, expectNotVisible, expectRowVisible, + expectToast, expectVisible, stopInstance, test, @@ -130,7 +132,7 @@ test('Detach disk', async ({ page }) => { // Have to stop instance to edit disks await stopInstance(page) - const successMsg = page.getByText('Disk detached').nth(0) + const successMsg = page.getByText('Disk disk-2 detached').first() const row = page.getByRole('row', { name: 'disk-2' }) await expect(row).toBeVisible() await expect(successMsg).toBeHidden() @@ -143,13 +145,13 @@ test('Detach disk', async ({ page }) => { test('Snapshot disk', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') - // have to use nth with toasts because the text shows up in multiple spots - const successMsg = page.getByText('Snapshot created').nth(0) - await expect(successMsg).toBeHidden() + // we don't know the full name of the disk, but this will work to find the toast + const toastMessage = /Snapshot disk-1-[a-z0-9]{6} created/ + await expectNoToast(page, toastMessage) await clickRowAction(page, 'disk-1', 'Snapshot') - await expect(successMsg).toBeVisible() // we see the toast! + await expectToast(page, toastMessage) // we see the toast! // now go see the snapshot on the snapshots page await page.getByRole('link', { name: 'Snapshots' }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index df0de16b04..ea17e815dc 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -8,7 +8,7 @@ import { expect, test } from '@playwright/test' -import { clickRowAction, expectRowVisible } from './utils' +import { clickRowAction, expectRowVisible, expectToast } from './utils' test('IP pool list', async ({ page }) => { await page.goto('/system/networking/ip-pools') @@ -118,10 +118,10 @@ test('IP pool delete from IP Pools list page', async ({ page }) => { await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('Could not delete resource').first()).toBeVisible() - await expect( - page.getByText('IP pool cannot be deleted while it contains IP ranges').first() - ).toBeVisible() + await expectToast( + page, + 'Could not delete resourceIP pool cannot be deleted while it contains IP ranges' + ) await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index e74c39e0ae..8545adde8c 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -115,6 +115,21 @@ export async function stopInstance(page: Page) { await expect(page.getByText('statestopped')).toBeVisible() } +/** + * Assert that a toast with text matching `expectedText` is visible. + */ +export async function expectToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).toHaveText(expectedText) + await closeToast(page) +} + +/** + * Assert that a toast with text matching `expectedText` is not visible. + */ +export async function expectNoToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).not.toHaveText(expectedText) +} + /** * Close toast and wait for it to fade out. For some reason it prevents things * from working, but only in tests as far as we can tell. From 78e7e26b8a16cabb08c92563b187f4b4fc6ae96e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 30 Oct 2024 03:15:02 +0000 Subject: [PATCH 06/12] fix(deps): update dependency @oxide/design-system to v1.4.7 (#2524) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 240f30f43f..d1c7d60400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1968,9 +1968,9 @@ "license": "MIT" }, "node_modules/@oxide/design-system": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.4.6.tgz", - "integrity": "sha512-arhKAI6sS/QUs1z5H30RQ+/bWPgKifE7KrwkVnDqjBRI4jirmAfa/td1X3hh8fN3ef3yUrkcECPjxygyQjikZg==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.4.7.tgz", + "integrity": "sha512-qhuCYGIbSztE6/JdhQx94CMSGbp3LDV/qnIdVAdImAyhwXvd3PHVJNk3WewJfzqkqfdv926HnZ3DDyPIFm9PUQ==", "license": "MPL 2.0", "dependencies": { "@figma-export/output-components-as-svgr": "^4.7.0", From 23824251c582ff0321633490fce0f225b29aad57 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 30 Oct 2024 14:33:43 +0000 Subject: [PATCH 07/12] Instance action buttons (#2508) * Instance action buttons * Re-add serial console link * Remove test styles * Fix broken `stopInstance` test util * be fussy * Button tooltip position default bottom (also flips to top) * Use correct info icon size * Add tooltip padding from edge of viewport * Removed disabled cursor for disabled button * Add confirm start instance * fix e2es * Only "running" instances can be stopped * `disabled:cursor-default` on button * use helper for symmetry --------- Co-authored-by: David Crespo --- app/components/DocsPopover.tsx | 4 +- app/pages/project/instances/InstancesPage.tsx | 10 ++-- app/pages/project/instances/actions.tsx | 60 ++++++++++++------- .../instances/instance/InstancePage.tsx | 28 +++++++-- app/table/columns/action-col.tsx | 2 +- app/ui/lib/Button.tsx | 4 +- app/ui/lib/Tooltip.tsx | 2 + package-lock.json | 6 +- test/e2e/instance.e2e.ts | 2 + test/e2e/utils.ts | 3 +- 10 files changed, 78 insertions(+), 43 deletions(-) diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index b393f167c5..76ddbcc791 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -9,7 +9,7 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' import cn from 'classnames' -import { OpenLink12Icon, Question12Icon } from '@oxide/design-system/icons/react' +import { Info16Icon, OpenLink12Icon } from '@oxide/design-system/icons/react' import { buttonStyle } from '~/ui/lib/Button' @@ -45,7 +45,7 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps) return ( - + [ + ...makeButtonActions(instance), + ...makeMenuActions(instance), + ]), ], - [project, makeActions] + [project, makeButtonActions, makeMenuActions] ) if (!instances) return null diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index b18886dd9a..6b50afd038 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -14,7 +14,6 @@ import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import type { MakeActions } from '~/table/columns/action-col' import { pb } from '~/util/path-builder' import { fancifyStates } from './instance/tabs/common' @@ -31,9 +30,8 @@ type Options = { export const useMakeInstanceActions = ( { project }: { project: string }, options: Options = {} -): MakeActions => { +) => { const navigate = useNavigate() - // if you also pass onSuccess to mutate(), this one is not overridden — this // one runs first, then the one passed to mutate(). // @@ -41,7 +39,7 @@ export const useMakeInstanceActions = ( // while the whole useMutation result object is not. The async ones are used // when we need to confirm because the confirm modals want that. const opts = { onSuccess: options.onSuccess } - const { mutate: startInstance } = useApiMutation('instanceStart', opts) + const { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts) const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts) const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts) // delete has its own @@ -49,22 +47,32 @@ export const useMakeInstanceActions = ( onSuccess: options.onDelete, }) - return useCallback( - (instance) => { - const instanceSelector = { project, instance: instance.name } + const makeButtonActions = useCallback( + (instance: Instance) => { const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { label: 'Start', onActivate() { - startInstance(instanceParams, { - onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore - onError: (error) => - addToast({ - variant: 'error', - title: `Error starting instance '${instance.name}'`, - content: error.message, + confirmAction({ + actionType: 'primary', + doAction: () => + startInstanceAsync(instanceParams, { + onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore + onError: (error) => + addToast({ + variant: 'error', + title: `Error starting instance '${instance.name}'`, + content: error.message, + }), }), + modalTitle: 'Confirm start instance', + modalContent: ( +

+ Are you sure you want to start {instance.name}? +

+ ), + errorTitle: `Error starting ${instance.name}`, }) }, disabled: !instanceCan.start(instance) && ( @@ -97,9 +105,20 @@ export const useMakeInstanceActions = ( }) }, disabled: !instanceCan.stop(instance) && ( - <>Only {fancifyStates(instanceCan.stop.states)} instances can be stopped + // don't list all the states, it's overwhelming + <>Only {fancifyStates(['running'])} instances can be stopped ), }, + ] + }, + [project, startInstanceAsync, stopInstanceAsync] + ) + + const makeMenuActions = useCallback( + (instance: Instance) => { + const instanceSelector = { project, instance: instance.name } + const instanceParams = { path: { instance: instance.name }, query: { project } } + return [ { label: 'Reboot', onActivate() { @@ -143,13 +162,8 @@ export const useMakeInstanceActions = ( }, ] }, - [ - project, - navigate, - deleteInstanceAsync, - rebootInstance, - startInstance, - stopInstanceAsync, - ] + [project, deleteInstanceAsync, navigate, rebootInstance] ) + + return { makeButtonActions, makeMenuActions } } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 9736ad1bef..f9c7fc30cc 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -26,6 +26,7 @@ import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStateBadge } from '~/components/StateBadge' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -92,7 +93,8 @@ export function InstancePage() { const instanceSelector = useInstanceSelector() const navigate = useNavigate() - const makeActions = useMakeInstanceActions(instanceSelector, { + + const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(instanceSelector, { onSuccess: refreshData, // go to project instances list since there's no more instance onDelete: () => { @@ -132,7 +134,7 @@ export function InstancePage() { { enabled: !!primaryVpcId } ) - const actions = useMemo( + const allMenuActions = useMemo( () => [ { label: 'Copy ID', @@ -140,9 +142,9 @@ export function InstancePage() { window.navigator.clipboard.writeText(instance.id || '') }, }, - ...makeActions(instance), + ...makeMenuActions(instance), ], - [instance, makeActions] + [instance, makeMenuActions] ) const memory = filesize(instance.memory, { output: 'object', base: 2 }) @@ -152,9 +154,23 @@ export function InstancePage() { }>{instance.name}
- - + +
+ {makeButtonActions(instance).map((action) => ( + + ))} +
+
diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index f18d16a04a..a880245b45 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -16,7 +16,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' import { kebabCase } from '~/util/str' -export type MakeActions = (item: Item) => Array +type MakeActions = (item: Item) => Array export type MenuAction = { label: string diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx index 1ced212a58..d77c893b6d 100644 --- a/app/ui/lib/Button.tsx +++ b/app/ui/lib/Button.tsx @@ -35,7 +35,7 @@ export const buttonStyle = ({ variant = 'primary', }: ButtonStyleProps = {}) => { return cn( - 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-not-allowed shrink-0', + 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-default shrink-0', `btn-${variant}`, sizeStyle[size], variant === 'danger' @@ -87,7 +87,7 @@ export const Button = forwardRef( return ( } + with={} >
- {p.description.length && ( + {!!p.description && (
{p.description}
)}
From 0552b62e35f954d43d11e390d3c1bed2ec8d9a2e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 30 Oct 2024 14:13:18 -0500 Subject: [PATCH 11/12] chore: bump actions to node 22 because it's LTS now (#2527) bump actions to node 22 because it's LTS now --- .github/workflows/lintBuildTest.yml | 4 ++-- .github/workflows/reformatter.yaml | 2 +- .github/workflows/upload-assets.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index b7cedaa85f..9d3c4fbcd8 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 diff --git a/.github/workflows/reformatter.yaml b/.github/workflows/reformatter.yaml index c163c4d522..15004f92c7 100644 --- a/.github/workflows/reformatter.yaml +++ b/.github/workflows/reformatter.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/upload-assets.yaml b/.github/workflows/upload-assets.yaml index 498ded32f4..1c275dcd09 100644 --- a/.github/workflows/upload-assets.yaml +++ b/.github/workflows/upload-assets.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: 'Authenticate to Google Cloud' uses: 'google-github-actions/auth@v2' From b4920b1157c5f63a8d5a39adcdb62c541ca870ec Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 31 Oct 2024 12:05:59 -0500 Subject: [PATCH 12/12] Tell password managers to stop trying to fill `name` field (#2530) tell password managers to stop trying to fill name field --- app/components/form/fields/NameField.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/components/form/fields/NameField.tsx b/app/components/form/fields/NameField.tsx index b369f4db98..7f3100c978 100644 --- a/app/components/form/fields/NameField.tsx +++ b/app/components/form/fields/NameField.tsx @@ -36,6 +36,11 @@ export function NameField< .replace(/[\s_]+/g, '-') .replace(/[^a-z0-9-]/g, '') } + // https://www.stefanjudis.com/snippets/turn-off-password-managers/ + data-1p-ignore + data-bwignore + data-lpignore="true" + data-form-type="other" {...textFieldProps} /> )